Эх сурвалжийг харах

feat(进件): 审核通知接收端(Task 6)—— 免登录入口的验签、定位闸与定向回写

上游在商户审核通过/驳回时 POST 一个固定地址,我们据此把
pay_lsy_merchant.review_status 从 PENDING 回写成 APPROVED / REJECTED。
这是本批次唯一的免登录入口,两个难点都在这里:身份靠验签、定位不到租户
(无认证上下文 ⇒ 租户拦截器不追加任何谓词)。

新增:
- LsyOnboardingReviewStatus:上游 page 196236556 的 reviewed_status → 本地列词表。
  该页参数表给数字(1.审核通过,2.审核驳回)、同页请求示例给中文("审核通过"),
  两种形态都认;精确匹配、不 trim(与本仓 LsyReviewStatus.of 同一条口径)。
- LsyReviewWriteOutcome:APPLIED / UNCHANGED / NOT_FOUND / AMBIGUOUS 四值。
  UNCHANGED 与 NOT_FOUND 都「没写库」但朝向相反,合成一个会让重放变成失败
  ⇒ 上游对一条已送达的通知无限重推。
- LsyOnboardingNotifyService:验签 → 读入站键 merchant_no → 解值域 → 定位写 → 应答。
  未知 reviewed_status ⇒ 应答 SUCCESS + 原样记日志(防无限重推,那条日志是将来补值域的唯一线索)。
  应答信封是 result_code / result_msg(page 196236556 的返回参数表与返回示例逐字),
  与本控制器另两条腿的 return_code / return_msg 是两套。

写入口 LsyMerchantService.updateReviewStatusByMchNo:
- selectList 后自己判 0 / 1 / 多行,多行一律不写并响亮失败。依据:mch_no 今天没有唯一索引
  (真库实测有 2 行,且两行各自都合法),而未认证查询不带租户谓词 ⇒ LIMIT 1 会静默取任意一行。
- 定向 SET 只写 review_status + updated_time;WHERE 用定位到那一行的 id
  (避开「定位与写入之间插入同 mch_no 行 ⇒ 一次改两行」的窗口)。
- 不复用 requireByMchNo(LIMIT 1 + 抛 404,形状正好相反),不复用 save(编辑分支写不了这一列)。

注释订正(零行为改动):
- LeshouyingNotifyController 类 javadoc 里那句信封说明限定到已有的两条腿。
- LsyMerchantService.requireByMchNo javadoc:索引尚未建立、原因、以及
  「在那之前未认证路径不得用 LIMIT 1 / selectOne」。
- TenantInnerInterceptor:pay_lsy_merchant 那条实例改成实况(免登录路径已存在、索引今天不存在、
  安全来自查询形状、索引是第二道闸);通则重述为「不得静默在多行里挑一行」,
  预防(全局唯一)与检测(查询形状)两条路并列,并写明两者都防不住
  「恰好命中一行、而那一行是别的租户的」。pay_lsy_notify_log 那条警告未动。

测试:新增 27 条(全量 567 → 594)。含用**真** service 的信封端到端断言、
反射读 SecurityConfig.WHITE_LIST 的落点断言、以及多行 / 幂等 / 未知值 /
日志注入(CWE-117)各一条。
变异验证:M-A1(selectList→selectOne+LIMIT 1)、M-A2(去掉多行闸)、M-B(result_code→return_code)、
M-C(删 updated_time 的 .set)四向全红;M-N(无害调换两个 .set 顺序)仍绿,
并以 javap 证明变异体确实进了被执行的 class。
alphaH 3 долоо хоног өмнө
parent
commit
14f6b996f2

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

@@ -80,11 +80,33 @@ public class TenantInnerInterceptor extends TenantLineInnerInterceptor {
             //   落到末尾 return false ⇒ 追加 AND tenant_id = 0 ⇒ 回调按 pay_trace_no / refund_no
             //   定位**恒为 null**。
             //   同类失效的实证见上面 pay_batch_authorize 那条注释。
-            //   无认证时跳过过滤 ⇒ 查询**不带租户条件**,故跨租户安全完全依赖业务键的唯一性。
-            //   ★★ 通则(**本清单里所有表都适用**):**未认证**的查询**只允许**用**有全局唯一
-            //      约束**的列定位。其余业务键都只有普通索引,用之前**必须先补唯一索引** ——
-            //      唯一性是无认证查询不串租户的**前提**,不是优化。(论证同上面
-            //      pay_batch_authorize 那条。)
+            //   无认证时跳过过滤 ⇒ 查询**不带租户条件** ⇒ 查询可能落到**别的租户**的行上。
+            //   ★★ 通则(**本清单里所有表都适用**,2026-09-23 重述):**未认证**的读,
+            //      **不得**让「定位到的那一行」有可能是**任意租户**的行,且**不得**用
+            //      selectOne / LIMIT 1 —— 它们把「多行」这件事**静默**成一行,而无认证查询
+            //      不带租户条件,静默挑中的那一行可能是别的租户的。被禁的从来是**静默**。
+            //      【怎么算满足】两条路,**任一条**即可:
+            //        (甲) **预防**:只用**有全局唯一约束**的列定位 ⇒ 多行在 schema 层就建不出来;
+            //        (乙) **检测**:定位列没有全局唯一约束时,用**查询形状**顶上 ——
+            //             selectList 之后自己判 0 行 / 1 行 / 多行,多行**一律不写**并响亮失败。
+            //      ★ 两种手段防的是**同一件事**(同一个键命中多行 ⇒ 不知道该写哪一行),
+            //        区别只在**预防 vs 检测**:(甲) 让那个状态不可达,(乙) 让那个状态被看见。
+            //        ★★ **两者都防不住**「恰好命中一行、而那一行是别的租户的」——
+            //        那是**键本身**的问题,不是 schema 的问题。所以无论走哪条路,定位键都必须
+            //        能说清「它为什么天然指向唯一那一行」(通常是**上游发放**的全局标识);
+            //        而由**租户自己提供**的键(单号之类)连 (甲) 都救不了 —— 见下面 out_trade_no 那组。
+            //      ★ (乙) **不是** (甲) 的等价替代:它没有预防力,且要多写一段判定。
+            //        走了 (乙) 的路径**仍然欠着**那个唯一索引 —— 索引是把业务约定变成 schema 事实,
+            //        (乙) 只是让违约被发现而不是被静默容忍。
+            //      ★ 下面各条**实例可以比本通则更严**(例如 pay_lsy_notify_log 那条要求
+            //        「必须先补唯一约束」,不收 (乙))—— 实例更严时**按实例**,本通则只是下限。
+            //      ✗ 已废弃的旧措辞(**不要照它做**):「其余业务键…用之前**必须先补唯一索引** ——
+            //        唯一性是无认证查询不串租户的**前提**」。两句都不成立:
+            //        ① 它把唯一性说成**前置**,而索引可能**建不出来**(有重复行)或**执行归用户**,
+            //           那不是可执行的顺序;② 更危险的是它会让人推出「有唯一性 ⇒ 定位只会有一行
+            //           ⇒ 用 selectOne / LIMIT 1 没问题」,而那正是本条**禁止**的写法。
+            //        改这条的实证见下面 pay_lsy_merchant 那条实例(2026-09-23)。
+            //      (论证同上面 pay_batch_authorize 那条 —— 那里走的是 (甲):out_biz_no 是雪花 ID。)
             //      【下面清单由 V1.8 **机械提取**(grep '^CREATE.*INDEX'),不是凭记忆枚举 ——
             //        人工枚举已漏过列(漏过 refund_trace_no),这正是必须立通则而非继续枚举的理由。
             //        新增索引后请同步重跑该 grep,别手改。】
@@ -99,9 +121,14 @@ public class TenantInnerInterceptor extends TenantLineInnerInterceptor {
             //              台账 §一「★★ 2026-09-22 裁定」。
             //        (2026-09-21:原先列在这里的 out_trade_no / out_refund_no 已**移入**下面的 ⚠ ——
             //          它们从不属于本清单的问题域,且其索引收窄成 (tenant_id, X) 后不再全局唯一。)
-            //      ✗ 非唯一 ⇒ 未认证查询**不得**用来定位:
+            //      ✗ 非唯一 ⇒ **不满足 (甲)**(下面哪一条想被未认证读用上,都必须先走 (乙) 并
+            //         说清「键为什么天然指向唯一那一行」):
             //          pay_lsy_order.trade_no        idx_lsy_order_trade_no
             //          pay_lsy_merchant.mch_no       idx_lsy_merchant_mch_no
+            //            ⚠ 2026-09-23:**已有一条未认证路径在用 mch_no 定位**(审核通知入口,
+            //              走 (乙):selectList 后多行一律不写)。本行原先写「不得用来定位」,
+            //              那句对**唯一性**仍成立(索引今天不存在),但对「能不能用」不成立 ——
+            //              见本条末尾那条实例。别从本行读成「这条路径违规」。
             //          pay_lsy_notify_log.trade_no   idx_lsy_notify_trade_no
             //                                        (trade_no, trade_type)
             //          pay_lsy_refund.order_id       idx_lsy_refund_order
@@ -156,11 +183,29 @@ public class TenantInnerInterceptor extends TenantLineInnerInterceptor {
             //       **义务**:任何**未认证**的读都**不得**用 selectOne —— 无认证查询不带租户条件,
             //       LIMIT 1 会返回**任意租户**的审计行(raw_body 含上游报文原文);
             //       要按业务键做未认证读,必须**先补唯一约束**(trade_no / pay_trace_no 均无)。
-            //   ★ pay_lsy_merchant 的 mch_no **没有唯一索引**(见 LsyMerchantService.requireByMchNo
-            //     的 javadoc)。当前**没有任何**未认证路径查询它(两条回调实测按 pay_trace_no /
-            //     refund_no 定位),所以本次加入是**惰性**的,不改变现有行为。
-            //     **义务**:将来一旦有人写按 mch_no 的**未认证**查询,必须先把 uk_lsy_merchant_mch_no
-            //     补上 —— 唯一性是无认证查询不串租户的前提(通则的实例)。
+            //   ★★ pay_lsy_merchant 的 mch_no **没有唯一索引**(见 LsyMerchantService.requireByMchNo
+            //     的 javadoc)。本条原写「当前**没有任何**未认证路径查询它…本次加入是**惰性**的」
+            //     —— **2026-09-23 起那句已假**:审核通知入口
+            //     (POST /payment/collection/notify/onboarding,LeshouyingNotifyController
+            //     → LsyOnboardingNotifyService)**免登录**,且只能按 mch_no 定位对方那一行。
+            //     ⇒ 它走的是通则的 (乙) 那条路,**不是** (甲):
+            //       · 安全来自**查询形状** —— LsyMerchantService.updateReviewStatusByMchNo
+            //         用 selectList 后自己判:0 行 ⇒ NOT_FOUND、>1 行 ⇒ AMBIGUOUS,
+            //         两种都**一行都不写**并让调用方应答 FAIL,只有恰好 1 行才定向写入;
+            //       · **不是**来自唯一性:索引 uk_lsy_merchant_mch_no **今天不存在**,而且
+            //         **建不起来** —— 真库实测 mch_no='Y52211090004' 有 2 行(同租户,
+            //         enterprise_id 一行有值一行 NULL,分别落在 uk_lsy_merchant_tenant_ent
+            //         与 uk_lsy_merchant_tenant_level 上,**两行各自都合法**)。
+            //         交付脚本 java/src/main/resources/db/lsy-merchant-mch-no-uk.sql 只做只读预检,
+            //         删哪一行是**用户的数据决策**。
+            //     **义务**:① 该索引仍是**第二道闸**(纵深防御 + 给将来按 mch_no 的未认证调用方
+            //     立约束),条件具备后补上;② 在它建好之前,任何按 mch_no 的**未认证**读都
+            //     **不得**用 selectOne / LIMIT 1,必须自己判多行;③ 索引建好后,
+            //     updateReviewStatusByMchNo 那段「安全来自查询形状」的措辞**仍不必改** ——
+            //     它是第二道闸,不是第一道(警告只在仍然成立时才删)。
+            //     ★ 本条**不走** (甲) 的反面证据:mch_no 是**上游发放**的平台商户号,
+            //     所以「它为什么天然指向唯一那一行」答得上来;而库里那两行是**同一租户**下的
+            //     租户级/企业级映射并存 —— 那是这张表的设计用法,不是脏数据。
             "pay_lsy_order",
             "pay_lsy_refund",
             "pay_lsy_merchant",

+ 23 - 1
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingNotifyController.java

@@ -1,6 +1,7 @@
 package com.payment.platform.module.payment.leshouying.controller;
 
 import com.payment.platform.module.payment.leshouying.service.LsyNotifyService;
+import com.payment.platform.module.payment.leshouying.service.LsyOnboardingNotifyService;
 import com.payment.platform.module.payment.leshouying.util.LsyLogSanitizer;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -16,9 +17,21 @@ import java.util.Map;
  * 乐收赢回调入口 —— 免登录(须加入 SecurityConfig 白名单)。
  *
  * 上游约定:POST + Content-Type: application/json,参数含 sign。
- * 必须应答 {"return_code":"SUCCESS","return_msg":"成功"},否则上游持续重推。
+ *
+ * ★★ 【应答信封按腿分两套,不是一个类一套】:
+ * <ul>
+ *   <li>{@code /pay}、{@code /refund} —— <b>交易族</b>(上游 {@code /v3/*} 那几页):
+ *       {@code {"return_code":"SUCCESS","return_msg":"成功"}},否则上游持续重推。
+ *       (本句原先是无限定的类级断言 —— 加了第三条腿之后它对整类<b>不再成立</b>,故在此限定。)</li>
+ *   <li>{@code /onboarding} —— <b>进件族</b>(上游 page 196236556,其返回参数表与返回示例逐字):
+ *       {@code {"result_code":"SUCCESS","result_msg":"操作成功"}}。
+ *       与交易族是<b>两个不同的键名</b>,照上面那句抄会把「我们收到了」说成另一件事,
+ *       而上游只会看到「回调一直失败」。信封常量在 {@code LsyOnboardingNotifyService}。</li>
+ * </ul>
  *
  * 【安全】入口无认证,签名是唯一身份证明。不提供任何跳过验签的路径。
+ * 报文里的任何字段都是不可信输入 ⇒ 打日志前一律过 {@link LsyLogSanitizer#flatten}
+ * (CWE-117:{@code \r\n} 能伪造出一整行日志),且这几行日志<b>刻意打在验签之前</b>。
  */
 @Slf4j
 @RestController
@@ -27,6 +40,15 @@ import java.util.Map;
 public class LeshouyingNotifyController {
 
     private final LsyNotifyService notifyService;
+    private final LsyOnboardingNotifyService onboardingNotifyService;
+
+    @PostMapping(value = "/onboarding", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String onboardingNotify(@RequestBody Map<String, Object> body) {
+        log.info("收到乐收赢审核通知: merchantNo={}, reviewedStatus={}",
+                LsyLogSanitizer.flatten(body.get("merchant_no")),
+                LsyLogSanitizer.flatten(body.get("reviewed_status")));
+        return onboardingNotifyService.handleOnboardingNotify(body, toJson(body));
+    }
 
     // ★ 这两行日志在**验签之前**,报文里的 \r\n 能直接伪造出一整行(CWE-117)。
     //   不能推到「验签过了再打」—— 验签失败恰恰是最需要看到「上游到底发了什么」的时候。

+ 92 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/enums/LsyOnboardingReviewStatus.java

@@ -0,0 +1,92 @@
+package com.payment.platform.module.payment.leshouying.enums;
+
+import cn.hutool.core.util.StrUtil;
+
+/**
+ * 上游「商户审核异步通知」(page 196236556)的 {@code reviewed_status} → <b>本地列的词表</b>。
+ *
+ * <p><b>这是一层翻译,不是一个值域</b>:左边是「上游说了什么」,右边是「我们库里写什么」
+ * ({@link LsyMerchantReviewStatus},落进 {@code pay_lsy_merchant.review_status})。
+ * 这层映射**只此一处**,别的类不许再各写一份 {@code "1" -> "APPROVED"}。
+ *
+ * <p>★★ <b>两种形态都得认,因为上游同一页里自己就给了两种</b>(page 196236556):
+ * <ul>
+ *   <li><b>请求参数表</b>那一行逐字是
+ *       {@code |reviewed_status|String(4)|是|商户审核状态|1.审核通过,2.审核驳回|} ⇒ 数字码;</li>
+ *   <li><b>同页的请求示例</b>逐字是 {@code "reviewed_status":"审核通过"} ⇒ 中文字符串
+ *       (正好 4 字,与参数表标称的 {@code String(4)} 对得上)。</li>
+ * </ul>
+ * 两处都在同一页,没有「哪一处更新」这种线索 ⇒ 只能两种都认。只认一种的话,
+ * 另一种形态的<b>每一条</b>通知都会落进「未知值 ⇒ 应答 SUCCESS、原样记日志」那条分支:
+ * 响亮、可查,但<b>不落库</b>,商户永远停在 {@code PENDING}。
+ *
+ * <p>★ <b>不得与 {@link LsyReviewStatus}(page 196236567 那一套)合并</b>:两套的码不同
+ * (那一套是 {@code 0} / {@code 9},本套是 {@code 1} / {@code 2}),合并就是替上游猜哪个码是什么意思。
+ * 三套 {@code reviewed_status} 值域各自实现、不做归一化 —— 见
+ * {@link LsyMerchantReviewStatus} 类 javadoc 里那条约束。
+ *
+ * <p>★ <b>也不得与 {@link LsyMerchantReviewStatus} 合并</b>:那个是本地列的词表,
+ * 它的类 javadoc 已经点名本枚举是「上游说了什么」那一侧。两者之间就是本枚举的
+ * {@link #getLocalStatus()}。
+ *
+ * <p><b>{@link #of} 是精确匹配、不 trim</b>(与 {@link LsyReviewStatus#of} 逐字同一条口径):
+ * 上游若真会补空白,症状是「每条通知都落进未知值分支」—— 响亮且可恢复(原值就在日志里),
+ * 而不是静默错映射。加 trim 之前先想清楚:那是在<b>放宽</b>本枚举认下的形态,
+ * 而文档里从来没有空白形态这一说。
+ */
+public enum LsyOnboardingReviewStatus {
+
+    /** 参数表 {@code 1.审核通过};请求示例 {@code "审核通过"}。 */
+    APPROVED("1", "审核通过", LsyMerchantReviewStatus.APPROVED),
+
+    /** 参数表 {@code 2.审核驳回};请求示例给的是通过那一条,驳回侧的对应中文串按同构取「审核驳回」。 */
+    REJECTED("2", "审核驳回", LsyMerchantReviewStatus.REJECTED);
+
+    private final String code;
+    private final String text;
+    private final LsyMerchantReviewStatus localStatus;
+
+    LsyOnboardingReviewStatus(String code, String text, LsyMerchantReviewStatus localStatus) {
+        this.code = code;
+        this.text = text;
+        this.localStatus = localStatus;
+    }
+
+    /** 参数表那一套的数字码({@code String(4)} 列宽下的取值)。 */
+    public String getCode() {
+        return code;
+    }
+
+    /** 请求示例那一套的中文串。 */
+    public String getText() {
+        return text;
+    }
+
+    /** 落进 {@code pay_lsy_merchant.review_status} 的那个词 —— 取自 {@link LsyMerchantReviewStatus#getValue()}。 */
+    public LsyMerchantReviewStatus getLocalStatus() {
+        return localStatus;
+    }
+
+    /**
+     * 上游报文里的 {@code reviewed_status} → 本枚举;**认不出来返回 {@code null},不抛**。
+     *
+     * <p>【为什么返回 null 而不是抛】调用方那条分支要的是「原样记日志 + 应答 SUCCESS
+     * 防上游无限重推」,它需要的是一个可判定的「不认识」。抛的后果是这条<b>未认证</b>入口
+     * 直接 500 —— 而上游那边只会看到「回调一直失败」。
+     *
+     * <p>【为什么不 parse 成数字】同页示例给的已经是中文串,{@code Integer.parseInt} 会直接抛;
+     * 而「{@code 1}」与「{@code "" + 1}」在类型上本来是同一批值的两种写法,
+     * 精确字符串匹配对两种形态是同一条判据(调用方负责把报文值取成文本形态)。
+     */
+    public static LsyOnboardingReviewStatus of(String raw) {
+        if (StrUtil.isBlank(raw)) {
+            return null;
+        }
+        for (LsyOnboardingReviewStatus s : values()) {
+            if (s.code.equals(raw) || s.text.equals(raw)) {
+                return s;
+            }
+        }
+        return null;
+    }
+}

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

@@ -0,0 +1,39 @@
+package com.payment.platform.module.payment.leshouying.enums;
+
+/**
+ * {@code LsyMerchantService.updateReviewStatusByMchNo} 的结果 —— <b>四个取值,不是一个 boolean</b>。
+ *
+ * <p><b>为什么不是一个 {@code boolean}/一个「成功 / 失败」</b>:那四个取值分属两条轴,
+ * 而这条路径上「没写库」有<u>两种含义相反</u>的原因:
+ * <ul>
+ *   <li>{@link #APPLIED} 按 {@code mch_no} 定位到<b>恰好一行</b>且值不同 ⇒ 已定向写入;</li>
+ *   <li>{@link #UNCHANGED} 定位到<b>恰好一行</b>但值已经相同 ⇒ <b>不写</b>(幂等:重放不刷
+ *       {@code updated_time})。<b>它和 {@link #APPLIED} 一样该应答成功</b>;</li>
+ *   <li>{@link #NOT_FOUND} 一行都没有 ⇒ 该应答失败,让上游重推;</li>
+ *   <li>{@link #AMBIGUOUS} 命中多行 ⇒ 该应答失败,<b>且一行都不许写</b>。</li>
+ * </ul>
+ * 把后两者合成一个 {@code false},调用方就再也没法告诉上游「这次到底哪不对」;
+ * 把前两者合成一个 {@code true},调用方又看不出「这条通知其实什么都没做」。
+ * 更要紧的是:{@code UNCHANGED} 与 {@code NOT_FOUND} <b>都表现为「没写库」</b>,
+ * 合成一个值会让<b>重放变成失败</b> ⇒ 上游对一条已送达的通知无限重推。
+ *
+ * <p><b>为什么 {@link #AMBIGUOUS} 必须是一个独立的、响亮的结果</b>:
+ * {@code pay_lsy_merchant.mch_no} <b>今天没有唯一索引</b>(真库里实测有重复,见
+ * {@code db/lsy-merchant-mch-no-uk.sql}),而本方法的调用方是无认证的审核通知入口 ——
+ * 它发出的查询<b>不带租户谓词</b>,随便挑一行就可能挑到<b>别的租户</b>的行上。
+ * 所以这条路径的第一道闸是<b>查询形状</b>(多行一律不写),唯一索引只是第二道闸。
+ */
+public enum LsyReviewWriteOutcome {
+
+    /** 定位到恰好一行,且 {@code review_status} 与目标值不同 ⇒ 已定向写入。 */
+    APPLIED,
+
+    /** 定位到恰好一行,但 {@code review_status} 已经等于目标值 ⇒ 不写(幂等)。 */
+    UNCHANGED,
+
+    /** 按 {@code mch_no} 一行都没查到(或 {@code mch_no} 本身是空白)⇒ 不写。 */
+    NOT_FOUND,
+
+    /** 按 {@code mch_no} 命中多行 ⇒ <b>不写</b>(无认证查询不带租户谓词,挑一行等于赌租户)。 */
+    AMBIGUOUS
+}

+ 127 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantService.java

@@ -7,7 +7,10 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.common.response.PageResult;
 import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
+import com.payment.platform.module.payment.leshouying.enums.LsyMerchantReviewStatus;
+import com.payment.platform.module.payment.leshouying.enums.LsyReviewWriteOutcome;
 import com.payment.platform.module.payment.leshouying.mapper.LsyMerchantMapper;
+import com.payment.platform.module.payment.leshouying.util.LsyLogSanitizer;
 import com.payment.platform.module.payment.leshouying.util.LsyStrings;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -15,6 +18,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.time.OffsetDateTime;
+import java.util.List;
 import java.util.stream.Collectors;
 
 /**
@@ -100,6 +104,23 @@ public class LsyMerchantService {
      * {@code mch_no} 上<b>没有唯一索引</b>(唯一约束是 {@code (tenant_id, enterprise_id)})。
      * 查询为 {@code LIMIT 1} 且无 {@code ORDER BY},一旦存在重复 {@code mch_no},
      * 返回的是<b>任意一行</b>。调用方须自行确认 {@code mch_no} 的唯一性。
+     *
+     * <p>★★ <b>2026-09-23 现况(不是「将来要修」)</b>:
+     * <ol>
+     *   <li>唯一索引 {@code uk_lsy_merchant_mch_no} <b>尚未建立</b>。原因不是没人写脚本 ——
+     *       脚本已经写好({@code java/src/main/resources/db/lsy-merchant-mch-no-uk.sql}),
+     *       但它<b>建不起来</b>:真库里 {@code mch_no='Y52211090004'} 就有 <b>2</b> 行
+     *       ({@code id} 1 与 2102313081860202497,同租户、{@code enterprise_id} 一行有值一行 NULL),
+     *       而这两行<b>各自都合法</b> —— 分别落在 {@code uk_lsy_merchant_tenant_ent} 与
+     *       {@code uk_lsy_merchant_tenant_level} 上。删哪一行是<b>用户的数据决策</b>,
+     *       脚本只做只读预检、不替你删。</li>
+     *   <li>⇒ <b>在那之前</b>,<b>未认证</b>路径<b>不得</b>依赖 {@code mch_no} 的唯一性,
+     *       也<b>不得</b>用 {@code LIMIT 1} / {@code selectOne} 定位(无认证查询不带租户谓词,
+     *       {@code LIMIT 1} 会返回任意租户的行)。要定位就得 {@code selectList} 之后自己判
+     *       0 行 / 1 行 / 多行 —— 见 {@link #updateReviewStatusByMchNo},那是这条路径的实例。</li>
+     *   <li>索引建好后,把本段改成「唯一性由它保证」。★ <b>警告只在它仍然成立时才删</b> ——
+     *       「因为打算修好所以先把警告删掉」正是让真警告失效的那条路径。</li>
+     * </ol>
      */
     public LsyMerchantEntity requireByMchNo(String mchNo) {
         if (StrUtil.isBlank(mchNo)) {
@@ -114,6 +135,112 @@ public class LsyMerchantService {
         return m;
     }
 
+    /**
+     * 审核通知的<b>写入口</b>:按 {@code mch_no} 定位,把 {@code review_status} 定向写成
+     * {@code localStatus},并把 {@code updated_time} 刷新成本次时刻。
+     *
+     * <p><b>为什么不复用 {@link #requireByMchNo}</b>(它名字正好、参数正好,是最可能被顺手调用的一个):
+     * 它的形状与本路径<b>正好相反</b> —— ① 它带 {@code .last("LIMIT 1")},<b>构造上</b>就发现不了重复行,
+     * 而「多行 ⇒ 响亮失败」正是本方法的全部要点;② 行不存在时它抛
+     * {@code BusinessException(404, ...)},把「找不到」变成异常路径,而本方法的调用方要的是
+     * 「拿一个结果去决定怎么应答上游」。两条都反 ⇒ 本方法自己 {@code selectList}。
+     * 也不要去「顺手修」它 —— 它的既有调用方在<b>已认证</b>路径上,改它就是动无关行为。
+     *
+     * <p><b>为什么不复用 {@link #save}</b>:它的编辑分支按 6 列字面枚举做定向更新,
+     * {@code review_status} 不在其中,而返回值(回读行)又与正常成功无法区分 —— 调用方
+     * 拿到一行的「成功」而库里那一列其实没动。类 javadoc 与 {@code save} 的 javadoc 都已点名本条。
+     *
+     * <h2>★★ 为什么必须是 {@code selectList},不能是 {@code selectOne} / {@code LIMIT 1}</h2>
+     * 本方法的调用方是<b>免登录</b>的审核通知入口:没有认证上下文 ⇒
+     * {@code TenantInnerInterceptor} 的 {@code ignoreTable} 返回 {@code true} ⇒
+     * <b>不追加任何租户谓词</b>。于是这次查询是<b>跨租户</b>的,
+     * 而 {@code pay_lsy_merchant.mch_no} <b>今天没有唯一索引</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} 上)。这不是「理论上可能重复」。
+     * ⇒ {@code LIMIT 1} 会<b>静默取任意一行</b>,取到的那一行可能是<b>别的租户</b>的。
+     *
+     * <p>所以本条路径的安全来自<b>查询形状</b>(多行一律不写),<b>不是</b>来自唯一性。
+     * 唯一索引 {@code uk_lsy_merchant_mch_no} 是<b>第二道闸</b>(纵深防御 + 为将来的调用方立约束),
+     * 它今天建不起来(重复行挡着,且执行归用户),交付脚本是 {@code db/lsy-merchant-mch-no-uk.sql}。
+     * 与 {@code TenantInnerInterceptor} 里那条「未认证的读不得用 {@code selectOne}」是同一件事。
+     *
+     * <h2>为什么 WHERE 用 {@code id} 而不是 {@code mch_no}</h2>
+     * 定位与写入之间若有人插进一行同 {@code mch_no} 的行(唯一索引还没建,插得进来),
+     * {@code WHERE mch_no = ?} 会<b>一次改两行</b>。主键定位没有这个窗口。
+     *
+     * @param mchNo          本地列 {@code mch_no} 的值(调用方从上游入站键 {@code merchant_no} 取)
+     * @param localStatus    要写的**本地词表**值(如 {@link LsyMerchantReviewStatus#APPROVED} 的
+     *                       {@code getValue()},不是 {@code name()},也不是上游的码)
+     * @param reviewedReason 上游给的驳回原因。★ 本批次<b>不落库</b>(表里没有 {@code reviewed_reason} 列),
+     *                       它是<b>日志通道</b>,不是备用的落库通道 —— 别为了「用上这个参数」而找地方存它。
+     * @return 四种结果之一,语义见 {@link LsyReviewWriteOutcome}。
+     *         ★ {@code UNCHANGED} 与 {@code NOT_FOUND} 都「没写库」但含义相反:
+     *         前者该应答成功(重放),后者该应答失败(让上游重推)。别把两者合成一个。
+     */
+    @Transactional
+    public LsyReviewWriteOutcome updateReviewStatusByMchNo(String mchNo, String localStatus, String reviewedReason) {
+        // 空白 mchNo 显式拦,不靠 `.eq(col, null)` 的隐含语义:那要求实现者始终记得
+        // 「MP 里 eq(col, null) 照发谓词、绑一个真 null」。入参来自**未验签报文**,
+        // 正是不该依赖隐含语义的地方。
+        if (StrUtil.isBlank(mchNo)) {
+            log.error("审核通知写入口收到空白商户号,不查库、不写入: mchNo=[{}], localStatus={}",
+                    LsyLogSanitizer.flatten(mchNo), LsyLogSanitizer.flatten(localStatus));
+            return LsyReviewWriteOutcome.NOT_FOUND;
+        }
+
+        // ★ selectList,不是 selectOne / LIMIT 1 —— 见方法 javadoc(多行闸就是这一步)。
+        List<LsyMerchantEntity> rows = merchantMapper.selectList(new LambdaQueryWrapper<LsyMerchantEntity>()
+                .eq(LsyMerchantEntity::getMchNo, mchNo));
+
+        if (rows.isEmpty()) {
+            log.error("审核通知找不到商户映射行,应答失败让上游重推: mchNo={}", LsyLogSanitizer.flatten(mchNo));
+            return LsyReviewWriteOutcome.NOT_FOUND;
+        }
+        if (rows.size() > 1) {
+            // 响亮失败,且**一行都不写**。日志里带上命中的 id:这是人工处置(或用户裁定删哪一行)
+            // 唯一能用的线索。
+            log.error("审核通知按 mch_no 命中多行,拒绝回写(该列无唯一索引,"
+                            + "无认证查询不带租户谓词 ⇒ 挑一行等于赌租户): mchNo={}, 命中 {} 行, ids={}",
+                    LsyLogSanitizer.flatten(mchNo), rows.size(),
+                    rows.stream().map(LsyMerchantEntity::getId).toList());
+            return LsyReviewWriteOutcome.AMBIGUOUS;
+        }
+
+        LsyMerchantEntity located = rows.get(0);
+        String current = located.getReviewStatus();
+        if (StrUtil.equals(current, localStatus)) {
+            // 幂等:同一通知重放(或上游重推)时值已相同 ⇒ **不写**。
+            // 写的话会把 updated_time 一直往后推,让「这一行最后一次真正变更是什么时候」失真。
+            log.info("审核通知重放,审核状态已是目标值,不写: mchNo={}, reviewStatus={}, reviewedReason={}",
+                    LsyLogSanitizer.flatten(mchNo), LsyLogSanitizer.flatten(current),
+                    LsyLogSanitizer.flatten(reviewedReason));
+            return LsyReviewWriteOutcome.UNCHANGED;
+        }
+
+        // 照 save 编辑分支那个既有先例:定向 SET,**不经实体**。
+        // ★ updated_time 必须显式写:update(null, wrapper) 不经过实体,拿不到
+        //   AutoFillMetaObjectHandler 的 strictUpdateFill(见 save 的 javadoc)。
+        // ★ 类型是 OffsetDateTime,由 ShanghaiOffsetDateTimeTypeHandler 负责时区 —— 别自己拼时间。
+        LambdaUpdateWrapper<LsyMerchantEntity> w = new LambdaUpdateWrapper<LsyMerchantEntity>()
+                .eq(LsyMerchantEntity::getId, located.getId())
+                .set(LsyMerchantEntity::getReviewStatus, localStatus)
+                .set(LsyMerchantEntity::getUpdatedTime, OffsetDateTime.now());
+        int updated = merchantMapper.update(null, w);
+        if (updated == 0) {
+            // 定位到写入之间那一行没了(并发删除 / 主键换了)。不抛:调用方要的是「怎么应答上游」,
+            // 而这种情况的正确答案与「找不到」同向 —— 让上游重推。
+            log.error("审核通知定向更新未命中任何行(定位与写入之间该行被改动过): mchNo={}, id={}",
+                    LsyLogSanitizer.flatten(mchNo), located.getId());
+            return LsyReviewWriteOutcome.NOT_FOUND;
+        }
+        log.info("审核通知已回写商户审核状态: mchNo={}, id={}, reviewStatus: {} -> {}, reviewedReason={}",
+                LsyLogSanitizer.flatten(mchNo), located.getId(),
+                LsyLogSanitizer.flatten(current), LsyLogSanitizer.flatten(localStatus),
+                LsyLogSanitizer.flatten(reviewedReason));
+        return LsyReviewWriteOutcome.APPLIED;
+    }
+
     public PageResult<LsyMerchantEntity> page(int pageNo, int pageSize, String keyword) {
         Page<LsyMerchantEntity> page = new Page<>(pageNo, pageSize);
         LambdaQueryWrapper<LsyMerchantEntity> w = new LambdaQueryWrapper<>();

+ 158 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOnboardingNotifyService.java

@@ -0,0 +1,158 @@
+package com.payment.platform.module.payment.leshouying.service;
+
+import cn.hutool.core.util.StrUtil;
+import com.payment.platform.module.payment.leshouying.client.LeshouyingSensitive;
+import com.payment.platform.module.payment.leshouying.client.LeshouyingSigner;
+import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
+import com.payment.platform.module.payment.leshouying.enums.LsyOnboardingReviewStatus;
+import com.payment.platform.module.payment.leshouying.enums.LsyReviewWriteOutcome;
+import com.payment.platform.module.payment.leshouying.util.LsyLogSanitizer;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
+
+/**
+ * 乐收赢「商户审核异步通知」接收端(上游 page 196236556)。
+ *
+ * <p><b>这是本批次唯一的免登录入口</b>(另两条免登录腿是既有的交易 / 退款回调,
+ * 在 {@link LsyNotifyService} 里)。它天然带两个风险:<b>身份靠验签</b>、
+ * <b>定位不到租户</b>(无认证上下文 ⇒ 租户拦截器不追加任何谓词)。本类与
+ * {@link LsyMerchantService#updateReviewStatusByMchNo} 合起来处理的正是这两件事。
+ *
+ * <p>★ <b>不并进 {@link LsyNotifyService}</b>:那两条腿按 {@code pay_trace_no} /
+ * {@code refund_no} 定位、信封是 {@code return_code},本条按 {@code mch_no} 定位、
+ * 信封是 {@code result_code}。往那个类里再塞第三个定位键,只会让它那段
+ * 「哪个键定位什么」的叙事更难读。
+ *
+ * <h2>① 身份:验签绝不跳过</h2>
+ * 入口无认证,{@code sign} 是<b>唯一</b>身份证明(Global Constraint 2)。本类
+ * <b>不提供任何跳过 / 降级 / 开关验签的路径</b>,验签失败即刻返回,
+ * <b>一条 SQL 都不发</b>(连读都不读 —— 报文不可信时,查询本身也是攻击面)。
+ *
+ * <h2>② 定位:入站键 {@code merchant_no},本地列 {@code mch_no}</h2>
+ * ★★ 两者<b>不同名</b>(上游参数表:{@code |merchant_no|String(15)|是|平台商户号|...})。
+ * 写成 {@code params.get("mch_no")} 会恒为 {@code null},症状是<b>每一条</b>通知都走
+ * 「商户号不存在」分支。值是同一个(本仓先例:{@code LsyShopCreateDTO} 的
+ * 「乐收赢商户号(上游 {@code merchant_no},必填)」+ 字段 {@code mchNo})。
+ * 定位本身在 {@link LsyMerchantService#updateReviewStatusByMchNo},那里有这道闸的完整理由。
+ *
+ * <h2>③ 应答信封:{@code result_code} / {@code result_msg}</h2>
+ * ★ <b>与本控制器另两条腿逐字不同</b>:page 196236556 的返回参数表与返回示例都是
+ * {@code {"result_code":"SUCCESS","result_msg":"操作成功"}},而 {@code /pay}、{@code /refund}
+ * 那两条腿的上游页给的是 {@code return_code} / {@code return_msg}。照类 javadoc 抄会
+ * 把这一条腿的信封写错 —— 而错的方向是「上游认为我们没收到」,于是无限重推。
+ *
+ * <h2>④ 应答朝向:FAIL 与 SUCCESS 的判据是「这次通知该不该再来一次」</h2>
+ * <ul>
+ *   <li><b>验签失败 / 行不存在 / 命中多行 ⇒ FAIL</b>:这三种情况我们都<b>没办成</b>,
+ *       而上游重推是有意义的(签名可能是密钥不同步;行可能还没建出来)。</li>
+ *   <li><b>未知 {@code reviewed_status} 值 ⇒ SUCCESS</b>:上游<u>确实</u>送到了有效地址、
+ *       报文也是真的,只是我们还不认识这个值 ⇒ 应答 FAIL 只会换来无限重推,
+ *       而重推一万次也变不出一个我们能认的值。<b>原样记日志</b>(那条日志是将来补值域的唯一线索)。</li>
+ * </ul>
+ * ★ 别为了「一致」把这两条统一成一种。
+ *
+ * <h2>⑤ 本类不写审计表</h2>
+ * 另两条腿各写一行 {@code pay_lsy_notify_log}(那里定位到单据后 {@code tenant_id} 是已知的)。
+ * 本条到写成为止都<b>没有</b>可信租户(行是刚按一个<b>可能重复</b>的键找到的),
+ * 落审计行会需要一个新的、语义未定的归属口径 —— 本批次不做。
+ * 报文原文进应用日志(脱敏 + 截断 + 摊平),够事后核对。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class LsyOnboardingNotifyService {
+
+    /** 成功应答。逐字取自 page 196236556 的返回示例。 */
+    private static final String OK = "{\"result_code\":\"SUCCESS\",\"result_msg\":\"操作成功\"}";
+
+    /** 验签失败。朝向 FAIL:让上游重推(签名对不上可能是密钥不同步,重推有意义)。 */
+    private static final String BAD_SIGN = "{\"result_code\":\"FAIL\",\"result_msg\":\"验签失败\"}";
+
+    /** 定位不到行。朝向 FAIL:多半是我们这边还没建这一行(或商户号给错了)。 */
+    private static final String BAD_NOT_FOUND = "{\"result_code\":\"FAIL\",\"result_msg\":\"商户号不存在\"}";
+
+    /** 命中多行。<b>与上一条是两个不同的常量</b> —— 合成一个就只能告诉上游「哪不对」里的一半。 */
+    private static final String BAD_AMBIGUOUS = "{\"result_code\":\"FAIL\",\"result_msg\":\"商户号命中多行,拒绝回写\"}";
+
+    /** 入站参数名:上游 page 196236556 的 {@code merchant_no}(**不是**本地列名 {@code mch_no})。 */
+    private static final String INBOUND_MCH_NO = "merchant_no";
+
+    /** 入站参数名:审核状态。 */
+    private static final String INBOUND_REVIEWED_STATUS = "reviewed_status";
+
+    /** 入站参数名:驳回原因。本批次**不落库**(表里没有这一列),只随日志走。 */
+    private static final String INBOUND_REVIEWED_REASON = "reviewed_reason";
+
+    /** 报文原文进日志前的截断长度,与 {@link LsyNotifyService} 同口径。 */
+    private static final int RAW_BODY_LOG_MAX = 500;
+
+    private final LeshouyingSigner signer;
+    private final LeshouyingProperties props;
+    private final LsyMerchantService merchantService;
+
+    /**
+     * 处理一条审核通知,返回给上游的<b>应答正文</b>({@code result_code} / {@code result_msg} 信封)。
+     *
+     * @param params  反序列化后的报文(不可信 —— 这是免登录入口)
+     * @param rawBody 报文原文,仅供日志;调用方已做过 JSON 化,本方法不再解析它
+     * @return 应答正文,取值是上面四个常量之一
+     */
+    public String handleOnboardingNotify(Map<String, Object> params, String rawBody) {
+        // 报文可能整个缺席({@code verify} 自己把 null / 空 map 判成 false,这里跟着它的口径走)。
+        // 这一行不是为了「好看」:下面每个 p.get 都在验签失败那条分支上,
+        // 而那条分支**必须**能打出「上游到底发了什么」—— 在那里 NPE 会让一次可疑回调
+        // 变成一个 500,日志里反而什么都没有。
+        Map<String, Object> p = (params == null) ? Map.of() : params;
+
+        // ── ① 身份。验签失败 ⇒ 到此为止:不读、不写、不应答成功。
+        if (!signer.verify(p, props.getKey())) {
+            // ★ 顺序与 LsyNotifyService 同款:先脱敏、后 brief(头+尾缩略)、最后 flatten。
+            //   flatten 必须在最外层:它只压行终止符、不减长度(见其 javadoc),
+            //   而这里也**必须**打报文原文 —— 验签失败恰恰是最需要看「上游到底发了什么」的时候。
+            //   报文完全不可信 ⇒ 每个实参都过 flatten(CWE-117:\r\n 能伪造出一整行日志)。
+            log.error("乐收赢审核通知验签失败,不落任何业务状态: merchantNo={}, reviewedStatus={}, rawBody={}",
+                    LsyLogSanitizer.flatten(str(p.get(INBOUND_MCH_NO))),
+                    LsyLogSanitizer.flatten(str(p.get(INBOUND_REVIEWED_STATUS))),
+                    LsyLogSanitizer.flatten(
+                            StrUtil.brief(LeshouyingSensitive.mask(rawBody), RAW_BODY_LOG_MAX)));
+            return BAD_SIGN;
+        }
+
+        // ── ② 定位键。入站是 merchant_no,本地要写的是 pay_lsy_merchant.mch_no。
+        String mchNo = str(p.get(INBOUND_MCH_NO));
+
+        // ── ③ 值域。认不出来 ⇒ 应答 SUCCESS + 原样记日志,**不猜**、不写库。
+        String rawStatus = str(p.get(INBOUND_REVIEWED_STATUS));
+        LsyOnboardingReviewStatus decoded = LsyOnboardingReviewStatus.of(rawStatus);
+        if (decoded == null) {
+            log.error("乐收赢审核通知的 reviewed_status 不在已知值域内,不写库、原样记下待补: "
+                            + "merchantNo={}, reviewed_status={}, rawBody={}",
+                    // ★ 这里的两处 flatten 不是装饰:rawStatus 直接来自未认证报文,
+                    //   而本行正是「原样记日志」那条 —— 原样 != 不设防。
+                    LsyLogSanitizer.flatten(mchNo),
+                    LsyLogSanitizer.flatten(rawStatus),
+                    LsyLogSanitizer.flatten(
+                            StrUtil.brief(LeshouyingSensitive.mask(rawBody), RAW_BODY_LOG_MAX)));
+            return OK;
+        }
+
+        // ── ④ 写。第三个参数是**日志通道**(表里没有 reviewed_reason 列),不是备用的落库通道。
+        LsyReviewWriteOutcome outcome = merchantService.updateReviewStatusByMchNo(
+                mchNo, decoded.getLocalStatus().getValue(), str(p.get(INBOUND_REVIEWED_REASON)));
+
+        // ── ⑤ 应答。APPLIED 与 UNCHANGED 都算「办成了」:后者是重放,它不该被当成错误 ——
+        //      否则上游会无限重推一条它已经送达过的通知。
+        return switch (outcome) {
+            case APPLIED, UNCHANGED -> OK;
+            case NOT_FOUND -> BAD_NOT_FOUND;
+            case AMBIGUOUS -> BAD_AMBIGUOUS;
+        };
+    }
+
+    private String str(Object o) {
+        return o == null ? null : String.valueOf(o);
+    }
+}

+ 222 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingNotifyControllerTest.java

@@ -0,0 +1,222 @@
+package com.payment.platform.module.payment.leshouying.controller;
+
+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.metadata.TableInfoHelper;
+import com.payment.platform.core.security.SecurityConfig;
+import com.payment.platform.module.payment.leshouying.client.LeshouyingSigner;
+import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
+import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
+import com.payment.platform.module.payment.leshouying.mapper.LsyMerchantMapper;
+import com.payment.platform.module.payment.leshouying.service.LsyMerchantService;
+import com.payment.platform.module.payment.leshouying.service.LsyNotifyService;
+import com.payment.platform.module.payment.leshouying.service.LsyOnboardingNotifyService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.slf4j.LoggerFactory;
+import org.springframework.util.AntPathMatcher;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 审核通知端点的<b>应答信封</b>与<b>落点</b>。
+ *
+ * <p>【为什么信封必须在这里断言,而不能只靠 {@code LeshouyingClientApiEnvelopeTest}】
+ * 那个类测的是<b>上游回给我们</b>的信封({@code /api/*} 的 {@code result_code});
+ * 本条要钉的是<b>我们回给上游</b>的信封。两者方向相反,名字又像,
+ * 正是「用一条间接证据替掉一条直接断言」最容易发生的地方。
+ *
+ * <p>【为什么本类用**真** service 而不是 mock 一个】信封串由
+ * {@code LsyOnboardingNotifyService} 产出,控制器只是把它原样返回。
+ * mock 掉 service 之后,这里的断言就变成「mock 返回什么、我断言什么」—— 恒真。
+ * 所以本类装配**真的** service(只把 mapper 换成假的,因此同样不碰数据库)。
+ *
+ * <p>【为什么还要读白名单】端点免登录这件事**不由本类决定**:它由
+ * {@code SecurityConfig.WHITE_LIST} 里的一条既有条目覆盖。路径写错(例如挪到
+ * {@code /payment/collection/onboarding})会让入口变成 401,而**没有任何测试会红** ——
+ * 上游那边只会看到「回调一直失败」。所以这里读**真实**白名单常量来判覆盖关系,
+ * 而不是在测试里自写一个字符串。
+ */
+@ExtendWith(MockitoExtension.class)
+class LeshouyingNotifyControllerTest {
+
+    private static final String KEY = "1c4d83f0c3c24a7d9fcaa1fed2682076";
+    private static final String MCH_NO = "Y52211090004";
+    private static final String ONBOARDING_PATH = "/payment/collection/notify/onboarding";
+
+    /** 上游 page 196236556 的返回示例逐字:{@code {"result_code":"SUCCESS","result_msg":"操作成功"}}。 */
+    private static final String OK = "{\"result_code\":\"SUCCESS\",\"result_msg\":\"操作成功\"}";
+    private static final String BAD_SIGN = "{\"result_code\":\"FAIL\",\"result_msg\":\"验签失败\"}";
+
+    @Mock private LsyMerchantMapper merchantMapper;
+
+    private LeshouyingSigner signer;
+    private LeshouyingNotifyController controller;
+
+    @BeforeEach
+    void setUp() {
+        signer = new LeshouyingSigner();
+        LeshouyingProperties props = new LeshouyingProperties();
+        props.setKey(KEY);
+
+        LsyMerchantService merchantService = new LsyMerchantService(merchantMapper);
+        // 另两条腿(/pay、/refund)不在本类射程内,它们有自己的 LsyNotifyServiceTest;
+        // 这里给一个 mock,免得为了凑构造参数而塞一串 null(构造参数一改就会以编译错误炸在这里)。
+        controller = new LeshouyingNotifyController(
+                mock(LsyNotifyService.class),
+                new LsyOnboardingNotifyService(signer, props, merchantService));
+
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), LsyMerchantEntity.class);
+    }
+
+    /**
+     * 成功应答的信封是 {@code result_code} / {@code result_msg}。
+     *
+     * <p>本控制器的**类 javadoc** 写的是 {@code return_code} / {@code return_msg} ——
+     * 那是它另两条腿({@code /pay}、{@code /refund})的口径,不是本条的。
+     * 照那句抄的实现,在这里红。
+     */
+    @Test
+    void onboardingNotify_successEnvelopeIsResultCode() {
+        LsyMerchantEntity row = new LsyMerchantEntity();
+        row.setId(1L);
+        row.setMchNo(MCH_NO);
+        row.setReviewStatus("PENDING");
+        when(merchantMapper.selectList(any())).thenReturn(List.of(row));
+        when(merchantMapper.update(any(), any())).thenReturn(1);
+
+        String response = controller.onboardingNotify(signed(body("审核通过")));
+
+        assertEquals(OK, response, "本条腿的信封是 result_code / result_msg,实测: " + response);
+        assertFalse(response.contains("return_code"),
+                "return_code 是另两条腿(/pay、/refund)的信封,不得出现在本条上: " + response);
+        assertFalse(response.contains("return_msg"), response);
+    }
+
+    /**
+     * 失败应答同样用 {@code result_code} / {@code result_msg}。
+     *
+     * <p>单钉成功那条不够:两条应答串是**两个常量**,把失败那条抄成
+     * {@code return_code} 一样能全绿。
+     */
+    @Test
+    void onboardingNotify_failEnvelopeIsAlsoResultCode() {
+        Map<String, Object> body = body("审核通过");
+        body.put("sign", "00000000000000000000000000000000");
+
+        String response = controller.onboardingNotify(body);
+
+        assertEquals(BAD_SIGN, response, "实测: " + response);
+        assertFalse(response.contains("return_code"), response);
+    }
+
+    /**
+     * 落点:{@code /payment/collection/notify/onboarding},且**被既有的白名单条目覆盖**
+     * (不新增白名单条目 —— 新增 {@code /**} 会让免认证面变大)。
+     *
+     * <p>三件事一起钉:<br>
+     * ① 类级 mapping + 方法级 mapping 拼出的**真实**路径就是那个地址;<br>
+     * ② 它是 {@code POST}(上游 page 196236556 逐字「请求方式:POST」);<br>
+     * ③ 它落在白名单某一项的模式内(用 Spring 自己的 {@link AntPathMatcher},不手写前缀判断)。
+     */
+    @Test
+    void onboardingNotify_mappingIsCoveredByAnExistingWhitelistEntry() throws Exception {
+        RequestMapping classMapping = LeshouyingNotifyController.class.getAnnotation(RequestMapping.class);
+        assertNotNull(classMapping, "回调控制器必须有类级 @RequestMapping");
+        Method method = LeshouyingNotifyController.class.getMethod("onboardingNotify", Map.class);
+        PostMapping postMapping = method.getAnnotation(PostMapping.class);
+        assertNotNull(postMapping, "审核通知必须是 POST —— 上游该页逐字「请求方式:POST」");
+
+        String fullPath = classMapping.value()[0] + postMapping.value()[0];
+        assertEquals(ONBOARDING_PATH, fullPath,
+                "给出的固定地址(onboardingNotifyUrl 的取值就是这个路径)与实现的映射必须是同一个");
+
+        Field f = SecurityConfig.class.getDeclaredField("WHITE_LIST");
+        f.setAccessible(true);
+        String[] whiteList = (String[]) f.get(null);
+
+        AntPathMatcher matcher = new AntPathMatcher();
+        List<String> covering = new ArrayList<>();
+        for (String pattern : whiteList) {
+            if (matcher.match(pattern, fullPath)) {
+                covering.add(pattern);
+            }
+        }
+        assertFalse(covering.isEmpty(),
+                "端点未被任何白名单条目覆盖 ⇒ 上游不带 JWT,回调恒 401。实际路径: " + fullPath);
+        assertTrue(covering.contains("/payment/collection/notify/**"),
+                "覆盖它的应当是那条既有条目(不新增白名单条目)。实际覆盖者: " + covering);
+    }
+
+    /**
+     * 控制器自己那行日志(在**验签之前**打)也必须过 {@code flatten}。
+     *
+     * <p>与 service 侧那条同名用例是**两个不同的汇点**:这行日志由控制器打,
+     * service 里的 flatten 管不到它。
+     */
+    @Test
+    void onboardingNotify_controllerLog_hasNoLineTerminators() {
+        String forged = "\r\n2026-01-01 00:00:00 ERROR 伪造的日志行";
+        Map<String, Object> body = body("1");
+        body.put("merchant_no", MCH_NO + forged);
+        body.put("reviewed_status", "1" + forged);
+
+        List<ILoggingEvent> captured = new ArrayList<>();
+        Logger logger = (Logger) LoggerFactory.getLogger(LeshouyingNotifyController.class);
+        ListAppender<ILoggingEvent> appender = new ListAppender<>();
+        appender.start();
+        logger.addAppender(appender);
+        try {
+            controller.onboardingNotify(body);
+            captured.addAll(appender.list);
+        } finally {
+            logger.detachAppender(appender);
+        }
+
+        assertFalse(captured.isEmpty(),
+                "没有捕获到任何日志事件 —— appender 很可能挂错了 logger,本用例已失效");
+        List<String> dirty = captured.stream()
+                .map(ILoggingEvent::getFormattedMessage)
+                .filter(m -> m.contains("\n") || m.contains("\r"))
+                .toList();
+        assertTrue(dirty.isEmpty(),
+                "日志里出现行终止符 ⇒ 未验签报文可伪造日志行(CWE-117);实际=" + dirty);
+    }
+
+    // ───────────────────────────────── 夹具 ─────────────────────────────────
+
+    private Map<String, Object> body(String reviewedStatus) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("inst_no", "900024");
+        m.put("merchant_no", MCH_NO);
+        m.put("merchant_name", "super超市");
+        m.put("reviewed_status", reviewedStatus);
+        m.put("reviewed_reason", "资料缺失");
+        return m;
+    }
+
+    private Map<String, Object> signed(Map<String, Object> body) {
+        Map<String, Object> m = new LinkedHashMap<>(body);
+        m.put("sign", signer.sign(m, KEY));
+        return m;
+    }
+}

+ 112 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/enums/LsyOnboardingReviewStatusTest.java

@@ -0,0 +1,112 @@
+package com.payment.platform.module.payment.leshouying.enums;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * {@link LsyOnboardingReviewStatus} —— 上游「商户审核异步通知」那一套值域的解析钉子。
+ *
+ * <p><b>两套值域为什么都要认(本任务最容易只做一半的地方)</b>:上游 page 196236556
+ * <b>同一页</b>里自相矛盾 ——
+ * <ul>
+ *   <li><b>请求参数表</b>那一行逐字是 {@code |reviewed_status|String(4)|是|商户审核状态|1.审核通过,2.审核驳回|}
+ *       ⇒ 数字码;</li>
+ *   <li><b>同页的请求示例</b>逐字是 {@code "reviewed_status":"审核通过"}
+ *       ⇒ 中文字符串(正好 4 字,与参数表标称的 {@code String(4)} 对上)。</li>
+ * </ul>
+ * 两处都在<b>同一页</b>,没有「哪一处更新」这种线索可用 ⇒ 只能<u>两种都认</u>。
+ * 只认一种的话,另一形态的每一条通知都会落进「未知值 ⇒ 应答 SUCCESS、原样记日志」那条分支 ——
+ * <b>响亮但不落库</b>,商户永远停在 {@code PENDING}。
+ *
+ * <p>★ <b>本枚举不得与 {@code LsyReviewStatus}(page 196236567 那一套)合并。</b>
+ * 两套的码不同(那一套是 {@code 0} / {@code 9},本套是 {@code 1} / {@code 2}),
+ * 合并就是把「上游说了什么」猜成一个。裁定见 {@code LsyMerchantReviewStatus} 的类 javadoc。
+ *
+ * <p>★ <b>也不得与 {@link LsyMerchantReviewStatus}(本地列的词表)合并</b>:
+ * 一个是「上游说了什么」,一个是「我们库里写什么」。本枚举的
+ * {@link LsyOnboardingReviewStatus#getLocalStatus()} 就是这两者之间那层映射,
+ * 它**只此一处**。
+ */
+class LsyOnboardingReviewStatusTest {
+
+    /** 参数表形态:{@code 1.审核通过,2.审核驳回}。 */
+    @Test
+    void of_acceptsTheNumericCodesFromTheParameterTable() {
+        assertSame(LsyOnboardingReviewStatus.APPROVED, LsyOnboardingReviewStatus.of("1"),
+                "参数表写 `1.审核通过` ⇒ \"1\" 必须解成 APPROVED");
+        assertSame(LsyOnboardingReviewStatus.REJECTED, LsyOnboardingReviewStatus.of("2"),
+                "参数表写 `2.审核驳回` ⇒ \"2\" 必须解成 REJECTED");
+    }
+
+    /** 请求示例形态:{@code "reviewed_status":"审核通过"}。 */
+    @Test
+    void of_acceptsTheChineseWordsFromTheRequestExample() {
+        assertSame(LsyOnboardingReviewStatus.APPROVED, LsyOnboardingReviewStatus.of("审核通过"),
+                "同页请求示例给的就是中文串 ⇒ 必须与数字码一样能解");
+        assertSame(LsyOnboardingReviewStatus.REJECTED, LsyOnboardingReviewStatus.of("审核驳回"));
+    }
+
+    /**
+     * 未知 / 空白 ⇒ {@code null},**不抛**。
+     *
+     * <p>与 {@code LsyReviewStatus.of} 同一条政策(那边逐字写着「同一页对一个字段两个映射」
+     * 的来历)。这里尤其不能抛:抛的后果是未认证入口 500,而正确行为是
+     * 「原样记日志 + 应答 SUCCESS 防无限重推」—— 那是**调用方**的分支,本方法只负责
+     * 回答「认不认识」。
+     */
+    @Test
+    void of_unknownOrBlank_isNull_andDoesNotThrow() {
+        assertNull(LsyOnboardingReviewStatus.of(null), "null ⇒ 未知,不得抛");
+        assertNull(LsyOnboardingReviewStatus.of(""), "空串 ⇒ 未知");
+        assertNull(LsyOnboardingReviewStatus.of("   "), "全空白 ⇒ 未知");
+        // "9" 是**另一套**值域(page 196236567)里的「审核驳回」——
+        // 拿错一套的码来解,正确结果是「不认识」,而不是「也当驳回」(那正是禁止的归一化猜测)
+        assertNull(LsyOnboardingReviewStatus.of("9"),
+                "别的接口那一套的码不得被本枚举认下 —— 认下就是跨接口归一化猜测");
+        assertNull(LsyOnboardingReviewStatus.of("3"));
+        assertNull(LsyOnboardingReviewStatus.of("审核通过 "), "末尾空格 => 未知");
+    }
+
+    /**
+     * **精确匹配,不 trim** —— 与 {@code LsyReviewStatus.of} 逐字同一条口径。
+     *
+     * <p>写这条是为了把「不 trim」变成**显式决定**而不是没人注意到的默认值:
+     * 上游若真会补空格,症状是「每次通知都落进未知值分支」—— 响亮(ERROR + 原始值入日志)、
+     * 可恢复(真值就在日志里),而不是静默错映射。
+     * ★ 加 trim 前先想清楚:那是**放宽**本枚举认下的形态,而文档里从来没有空白形态这一说。
+     */
+    @Test
+    void of_isExactMatch_doesNotTrim() {
+        assertNull(LsyOnboardingReviewStatus.of(" 1"));
+        assertNull(LsyOnboardingReviewStatus.of("1 "));
+        assertNull(LsyOnboardingReviewStatus.of("\t审核通过"));
+    }
+
+    /**
+     * 中间那层映射落在枚举上,且落到**本地列的词表**上({@code getValue()},不是 {@code name()})。
+     *
+     * <p>断言用**字面串**:{@code LsyMerchantReviewStatus} 的常量名与落进
+     * {@code pay_lsy_merchant.review_status}({@code VARCHAR(32)})的那个词之间那层间接,
+     * 正是这条要保护的东西。
+     */
+    @Test
+    void mapsToTheLocalWordTable_byValueNotName() {
+        assertSame(LsyMerchantReviewStatus.APPROVED, LsyOnboardingReviewStatus.APPROVED.getLocalStatus());
+        assertSame(LsyMerchantReviewStatus.REJECTED, LsyOnboardingReviewStatus.REJECTED.getLocalStatus());
+
+        assertEquals("APPROVED", LsyOnboardingReviewStatus.APPROVED.getLocalStatus().getValue());
+        assertEquals("REJECTED", LsyOnboardingReviewStatus.REJECTED.getLocalStatus().getValue());
+    }
+
+    /**
+     * 正对照:枚举**恰有**两个取值。
+     *
+     * <p>没有这条,往枚举里加第三个常量(比如把 {@code PENDING} 也塞进来 —— 那是本地词表的事,
+     * 上游通知永远不会送它)不会有任何红灯。
+     */
+    @Test
+    void values_areExactlyTheTwoTheNotificationCanCarry() {
+        assertEquals(2, LsyOnboardingReviewStatus.values().length);
+    }
+}

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

@@ -1,11 +1,14 @@
 package com.payment.platform.module.payment.leshouying.service;
 
 import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
 import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
+import com.payment.platform.module.payment.leshouying.enums.LsyReviewWriteOutcome;
 import com.payment.platform.module.payment.leshouying.mapper.LsyMerchantMapper;
 import org.apache.ibatis.builder.MapperBuilderAssistant;
 import org.junit.jupiter.api.BeforeEach;
@@ -404,6 +407,167 @@ class LsyMerchantServiceTest {
         verify(merchantMapper, never()).update(any(), any());
     }
 
+    // ──────────────── updateReviewStatusByMchNo:乐收赢审核通知的写入口 ────────────────
+    //
+    // 【为什么这个写入口不复用 requireByMchNo,也不复用 save】
+    //   · requireByMchNo 的形状与本路径**正好相反**:它 `.last("LIMIT 1")`(构造上发现不了重复行),
+    //     且行不存在时抛 404(把「找不到」变成异常路径)。而本节要的是
+    //     「0 行 / 多行各自响亮地说出来」,因为本条路径**没有认证上下文**。
+    //   · save 的编辑分支写不了 review_status,返回值又与正常成功无法区分。
+    //
+    // 【为什么必须是 selectList 而不是 selectOne / LIMIT 1】
+    //   pay_lsy_merchant 在 mch_no 上**今天没有唯一索引** —— 2026-09-23 实测真库里有重复
+    //   (mch_no='Y52211090004' 两行,且两行都合法:一行 enterprise_id 有值、一行 NULL)。
+    //   未认证查询不带租户谓词,`LIMIT 1` 会**静默取任意一行**,而那一行可能是别的租户的。
+    //   ⇒ 安全来自**查询形状**,不来自唯一性(唯一性是第二道闸,见 db/lsy-merchant-mch-no-uk.sql)。
+
+    /**
+     * 恰好一行 ⇒ 定向写 {@code review_status} + {@code updated_time},**恰好这两列**。
+     *
+     * <p>集合相等断言(而不是 contains):多一列意味着覆盖了别的列({@code save} 那条 D-1
+     * 静默数据缺陷就是这一形态),少一列意味着状态写不进去。
+     */
+    @Test
+    void updateReviewStatusByMchNo_exactlyOneRow_writesExactlyTwoColumns() {
+        LsyMerchantEntity located = rowWithReviewStatus(EDIT_ID, "PENDING");
+        when(merchantMapper.selectList(any())).thenReturn(List.of(located));
+        when(merchantMapper.update(any(), any())).thenReturn(1);
+
+        LsyReviewWriteOutcome outcome =
+                service.updateReviewStatusByMchNo("Y52211090004", "APPROVED", "资料缺失");
+
+        assertEquals(LsyReviewWriteOutcome.APPLIED, outcome, "恰好一行且值不同 ⇒ APPLIED");
+
+        LambdaUpdateWrapper<?> w = capturedUpdate();
+        Set<String> cols = setColumns(w);
+        assertEquals(Set.of("review_status", "updated_time"), cols,
+                "SET 必须恰好是这两列。实测: " + w.getSqlSet());
+        assertFalse(cols.contains("status"), "status 不在本方法的职责里(它管的是启用/停用)");
+        assertFalse(cols.contains("mch_no"), "mch_no 只是 WHERE 条件,不得进 SET");
+        assertFalse(cols.contains("tenant_id"), "tenant_id 不得进 SET");
+        assertFalse(cols.contains("id"), "id 只是 WHERE 条件,不得进 SET");
+
+        // 写进去的就是入参那个词(不是 name()、不是别的枚举的词)
+        assertEquals("APPROVED", setValue(w, "review_status"),
+                "实测 SET 参数表: " + w.getParamNameValuePairs());
+
+        // updated_time 必须是 OffsetDateTime 且是本次时刻 —— update(null, wrapper) 不经过实体,
+        // 拿不到 AutoFillMetaObjectHandler 的 strictUpdateFill,所以它只能由本方法显式写。
+        List<OffsetDateTime> stamps = w.getParamNameValuePairs().values().stream()
+                .filter(OffsetDateTime.class::isInstance).map(OffsetDateTime.class::cast)
+                .collect(Collectors.toList());
+        assertEquals(1, stamps.size(),
+                "SET 里应恰好一个时间戳参数(updated_time),实测: " + w.getParamNameValuePairs());
+        assertTrue(Duration.between(stamps.get(0), OffsetDateTime.now()).abs().toSeconds() < 60,
+                "updated_time 必须是本次写入的时刻,实测: " + stamps.get(0));
+
+        // 旧写法就是 updateById:NOT_NULL 策略会把实体上所有非 null 列都塞进 SET
+        verify(merchantMapper, never()).updateById(any());
+        verify(merchantMapper, never()).insert(any());
+    }
+
+    /**
+     * WHERE 指向**定位到那一行的 id**,而不是 {@code mch_no}。
+     *
+     * <p>用 {@code id} 的理由不是审美:定位与写入之间若有人插进一行同 {@code mch_no} 的行,
+     * {@code WHERE mch_no = ?} 会**一次改两行**(而唯一索引今天还不存在,插得进来)。
+     * 主键定位没有这个窗口。
+     */
+    @Test
+    void updateReviewStatusByMchNo_whereTargetsTheLocatedRowId() {
+        LsyMerchantEntity located = rowWithReviewStatus(EDIT_ID, "PENDING");
+        when(merchantMapper.selectList(any())).thenReturn(List.of(located));
+        when(merchantMapper.update(any(), any())).thenReturn(1);
+
+        service.updateReviewStatusByMchNo("Y52211090004", "REJECTED", null);
+
+        LambdaUpdateWrapper<?> w = capturedUpdate();
+        String where = w.getSqlSegment();
+        assertTrue(where.contains("id = "),
+                "WHERE 必须按主键定位(而不是 mch_no —— 那会一次改多行)。实测: [" + where + "]");
+        Matcher ph = Pattern.compile("MPGENVAL\\d+").matcher(where);
+        assertTrue(ph.find(), "WHERE 里应有一个占位符。实测: [" + where + "]");
+        assertEquals(Long.valueOf(EDIT_ID), w.getParamNameValuePairs().get(ph.group()),
+                "WHERE 绑的应是定位到那一行的 id,实测: " + w.getParamNameValuePairs());
+
+        // 定位查询绑的是入参 mch_no(列名那个键)
+        AbstractWrapper<?, ?, ?> q = capturedSelectList();
+        assertEquals("Y52211090004", boundPredicateValue(q, "mch_no"),
+                "定位谓词应绑入参 mchNo,实测: " + q.getSqlSegment());
+    }
+
+    /** 0 行 ⇒ {@code NOT_FOUND},且**一行都不写**。 */
+    @Test
+    void updateReviewStatusByMchNo_zeroRows_returnsNotFound_andWritesNothing() {
+        when(merchantMapper.selectList(any())).thenReturn(List.of());
+
+        LsyReviewWriteOutcome outcome =
+                service.updateReviewStatusByMchNo("Y52211090004", "APPROVED", null);
+
+        assertEquals(LsyReviewWriteOutcome.NOT_FOUND, outcome);
+        verify(merchantMapper, never()).update(any(), any());
+        verify(merchantMapper, never()).updateById(any());
+    }
+
+    /**
+     * 多行 ⇒ {@code AMBIGUOUS},且**一行都不写**。
+     *
+     * <p>这是本写入口存在的**主要理由**:{@code mch_no} 今天真的重复(真库实测 2 行),
+     * 而这条路径免登录、查询不带租户谓词 —— 随便挑一行的后果是**写到别的租户头上**。
+     */
+    @Test
+    void updateReviewStatusByMchNo_multipleRows_returnsAmbiguous_andWritesNothing() {
+        when(merchantMapper.selectList(any())).thenReturn(List.of(
+                rowWithReviewStatus(1L, "PENDING"),
+                rowWithReviewStatus(EDIT_ID, "PENDING")));
+
+        LsyReviewWriteOutcome outcome =
+                service.updateReviewStatusByMchNo("Y52211090004", "APPROVED", null);
+
+        assertEquals(LsyReviewWriteOutcome.AMBIGUOUS, outcome,
+                "多行不得「取第一行」—— 无认证查询拿到的可能是别的租户的行");
+        verify(merchantMapper, never()).update(any(), any());
+        // 多行闸没了的最可能写法就是 selectOne:它构造上发现不了重复
+        verify(merchantMapper, never()).selectOne(any());
+    }
+
+    /**
+     * 值已相同 ⇒ {@code UNCHANGED},**不写**(幂等:同一条通知重放不刷 {@code updated_time})。
+     *
+     * <p>★ 返回 {@code UNCHANGED} 而不是 {@code NOT_FOUND}:调用方要据此应答**成功**。
+     * 两者都「没写库」,但一个该让上游重推、一个不该 —— 混成一个值就会让重放变成 FAIL,
+     * 而上游会因此**无限重推**一条它已经送达过的通知。
+     */
+    @Test
+    void updateReviewStatusByMchNo_sameValue_isUnchanged_andWritesNothing() {
+        when(merchantMapper.selectList(any()))
+                .thenReturn(List.of(rowWithReviewStatus(EDIT_ID, "APPROVED")));
+
+        LsyReviewWriteOutcome outcome =
+                service.updateReviewStatusByMchNo("Y52211090004", "APPROVED", "资料缺失");
+
+        assertEquals(LsyReviewWriteOutcome.UNCHANGED, outcome);
+        verify(merchantMapper, never()).update(any(), any());
+    }
+
+    /**
+     * 空白 {@code mchNo} ⇒ {@code NOT_FOUND},且**一条 SQL 都不发**。
+     *
+     * <p>显式拦而不是靠 {@code .eq(col, null)} 的语义:那要求实现者始终记得
+     * 「MP 里 {@code eq(col, null)} 照发谓词、绑一个真 null」(本项目已经为这条语义吃过一次亏)。
+     * 入参来自**未验签报文**,正是不该依赖隐含语义的地方。
+     */
+    @Test
+    void updateReviewStatusByMchNo_blankMchNo_returnsNotFound_andIssuesNoQuery() {
+        assertEquals(LsyReviewWriteOutcome.NOT_FOUND,
+                service.updateReviewStatusByMchNo(null, "APPROVED", null));
+        assertEquals(LsyReviewWriteOutcome.NOT_FOUND,
+                service.updateReviewStatusByMchNo("   ", "APPROVED", null));
+
+        verify(merchantMapper, never()).selectList(any());
+        verify(merchantMapper, never()).update(any(), any());
+    }
+
     // ─────────────────────────────────── 夹具 ───────────────────────────────────
 
     /** 页面送来的编辑请求体:只有 `id` + 5 个业务键(`merchant/index.vue` 的 FORM_FIELDS),服务端字段一个都不带 */
@@ -435,4 +599,37 @@ class LsyMerchantServiceTest {
                 .map(s -> s.substring(0, s.indexOf('=')).trim())
                 .collect(Collectors.toSet());
     }
+
+    // ──────────────── updateReviewStatusByMchNo 用的夹具与解析 ────────────────
+
+    /** 定位查询实际用掉的 wrapper(`any()` 会把「按哪个键查」整个吃掉,所以必须单独取出来看)。 */
+    private AbstractWrapper<?, ?, ?> capturedSelectList() {
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Wrapper<LsyMerchantEntity>> captor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(merchantMapper).selectList(captor.capture());
+        return (AbstractWrapper<?, ?, ?>) captor.getValue();
+    }
+
+    /** 解析 WHERE 里 `列 = ?` 谓词绑定的**值**(MP 只把占位符写进 SQL 文本,真值在参数表里)。 */
+    private static Object boundPredicateValue(AbstractWrapper<?, ?, ?> w, String column) {
+        Matcher m = Pattern.compile(column + " = #\\{ew\\.paramNameValuePairs\\.([A-Za-z0-9_]+)\\}")
+                .matcher(w.getSqlSegment());
+        assertTrue(m.find(), "WHERE 里应有 " + column + " = ? 谓词,实测: " + w.getSqlSegment());
+        return w.getParamNameValuePairs().get(m.group(1));
+    }
+
+    /** 解析 SET 里某一列绑定的值。 */
+    private static Object setValue(LambdaUpdateWrapper<?> w, String column) {
+        Matcher m = Pattern.compile(column + "=#\\{ew\\.paramNameValuePairs\\.([A-Za-z0-9_]+)\\}")
+                .matcher(w.getSqlSet());
+        assertTrue(m.find(), "SET 里应有 " + column + ",实测: " + w.getSqlSet());
+        return w.getParamNameValuePairs().get(m.group(1));
+    }
+
+    /** 被定位到的那一行(带审核状态 —— 幂等判据要读它)。 */
+    private static LsyMerchantEntity rowWithReviewStatus(long id, String reviewStatus) {
+        LsyMerchantEntity r = row(id);
+        r.setReviewStatus(reviewStatus);
+        return r;
+    }
 }

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

@@ -0,0 +1,448 @@
+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 cn.hutool.json.JSONUtil;
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.payment.platform.module.payment.leshouying.client.LeshouyingSigner;
+import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
+import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
+import com.payment.platform.module.payment.leshouying.mapper.LsyMerchantMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+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.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Supplier;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * 乐收赢审核通知接收端(上游 page 196236556)。
+ *
+ * <p><b>这是本批次唯一的免登录入口</b>(另两条免登录腿是既有的交易 / 退款回调),
+ * 所以本类的用例集中在两件事上:<b>身份(验签)</b>与<b>定位</b>。
+ *
+ * <p>★★ <b>定位那条闸为什么必须用「真服务 + 假 mapper」来测</b>:
+ * 定位不在本类里 —— 它在 {@code LsyMerchantService.updateReviewStatusByMchNo},
+ * 而那道闸的形状({@code selectList} 后自己判 0 / 1 / 多行)正是
+ * {@code onboardingNotify_duplicateMchNo_returnsFail} 要钉的东西。
+ * 若把 {@code LsyMerchantService} 整个 mock 掉,那条用例就退化成
+ * 「mock 说 false ⇒ 我回 FAIL」—— <b>把定位查询改成 {@code selectOne} + {@code LIMIT 1}
+ * 它照样绿</b>,等于没钉。所以这里用**真** {@code LsyMerchantService}
+ * (外面包一层 spy,只为能 verify「没走 {@code save}」),只把 mapper 换成假的。
+ *
+ * <p>★ 本类**不碰数据库**(mapper 是 mock),与 {@code mvn test} 的库隧道依赖无关。
+ */
+@ExtendWith(MockitoExtension.class)
+class LsyOnboardingNotifyServiceTest {
+
+    /** 与 `LsyNotifyServiceTest` 同源:application.yml 里那个**文档测试**机构密钥。 */
+    private static final String KEY = "1c4d83f0c3c24a7d9fcaa1fed2682076";
+
+    /** 与库里那两行重复夹具同值 —— 见 `db/lsy-merchant-mch-no-uk.sql` 第 0.1 步的实测读数。 */
+    private static final String MCH_NO = "Y52211090004";
+
+    // ── 应答信封 ────────────────────────────────────────────────────────────────
+    // ★ 逐字断言,不写成 contains("result_code") —— 那样把键改名成 return_code 也照样绿。
+    //   信封是 result_code / result_msg(page 196236556 的**返回参数表**与**返回示例**逐字都是它),
+    //   与本控制器里另两条腿的 return_code / return_msg 是**两套**,见其类 javadoc。
+    private static final String OK = "{\"result_code\":\"SUCCESS\",\"result_msg\":\"操作成功\"}";
+    private static final String BAD_SIGN = "{\"result_code\":\"FAIL\",\"result_msg\":\"验签失败\"}";
+    private static final String BAD_NOT_FOUND = "{\"result_code\":\"FAIL\",\"result_msg\":\"商户号不存在\"}";
+    private static final String BAD_AMBIGUOUS = "{\"result_code\":\"FAIL\",\"result_msg\":\"商户号命中多行,拒绝回写\"}";
+
+    @Mock private LsyMerchantMapper merchantMapper;
+
+    private LeshouyingSigner signer;
+    private LsyMerchantService merchantService;
+    private LsyOnboardingNotifyService service;
+
+    /** 被 `update` 认领的那一行。 */
+    private LsyMerchantEntity row;
+
+    @BeforeEach
+    void setUp() {
+        signer = new LeshouyingSigner();
+        LeshouyingProperties props = new LeshouyingProperties();
+        props.setKey(KEY);
+
+        // spy 而不是 mock:真方法照跑(定位与写入都是真的),但能 verify「没调过哪个方法」。
+        merchantService = spy(new LsyMerchantService(merchantMapper));
+        service = new LsyOnboardingNotifyService(signer, props, merchantService);
+
+        row = new LsyMerchantEntity();
+        row.setId(2102313081860202497L);
+        row.setTenantId(2L);
+        row.setMchNo(MCH_NO);
+        row.setReviewStatus("PENDING");
+
+        // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper / LambdaUpdateWrapper
+        // 在无 Spring 上下文的单测里可用(否则抛 MybatisPlusException: can not find lambda cache)。
+        // 同款引导见 LsyMerchantServiceTest / LsyNotifyServiceTest。
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), LsyMerchantEntity.class);
+    }
+
+    // ───────────────────────────────── 身份 ─────────────────────────────────
+
+    /**
+     * 验签失败 ⇒ <b>不写任何业务状态</b> + 应答 FAIL。
+     *
+     * <p>Global Constraint 2「验签绝不跳过」:入口无认证,签名是唯一身份证明。
+     * 断言分两层,缺一不可:<br>
+     * ① 应答是 FAIL(让上游重推 —— 报文可能是真的,只是签名不对);<br>
+     * ② <b>一条 SQL 都没发</b>。只断言 ① 的话,一个「先写库再验签」的实现照样绿。
+     */
+    @Test
+    void onboardingNotify_rejectsBadSign() {
+        Map<String, Object> body = onboardingBody("审核通过");
+        body.put("sign", "00000000000000000000000000000000");
+
+        String response = service.handleOnboardingNotify(body, json(body));
+
+        assertEquals(BAD_SIGN, response, "验签失败必须应答 FAIL,实测: " + response);
+        verify(merchantMapper, never()).selectList(any());
+        verify(merchantMapper, never()).update(any(), any());
+        verify(merchantService, never()).updateReviewStatusByMchNo(any(), any(), any());
+    }
+
+    // ───────────────────────────────── 值域 ─────────────────────────────────
+
+    /**
+     * ★★ 两种形态都认 —— page 196236556 **同一页**里参数表写数字({@code 1.审核通过,2.审核驳回})、
+     * 请求示例写中文({@code "reviewed_status":"审核通过"}),没有「哪一处更新」这种线索,
+     * 只能两种都认。只认一种 ⇒ 另一种的**每一条**通知都落进「未知值」分支(不落库)。
+     */
+    @Test
+    void onboardingNotify_acceptsBothStatusShapes() {
+        Map<String, String> expected = new LinkedHashMap<>();
+        expected.put("1", "APPROVED");
+        expected.put("审核通过", "APPROVED");
+        expected.put("2", "REJECTED");
+        expected.put("审核驳回", "REJECTED");
+
+        for (Map.Entry<String, String> e : expected.entrySet()) {
+            row.setReviewStatus("PENDING");     // 每轮从同一个起点出发
+            stubOneRow();
+
+            String response = service.handleOnboardingNotify(
+                    signed(onboardingBody(e.getKey())), "{}");
+
+            assertEquals(OK, response, "形态 [" + e.getKey() + "] 应答是 " + response);
+            assertEquals(e.getValue(), row.getReviewStatus(),
+                    "形态 [" + e.getKey() + "] 应写成 " + e.getValue());
+        }
+    }
+
+    @Test
+    void onboardingNotify_mapsApprovedToApproved() {
+        stubOneRow();
+
+        service.handleOnboardingNotify(signed(onboardingBody("1")), "{}");
+
+        assertEquals("APPROVED", row.getReviewStatus(),
+                "本地列写的是 LsyMerchantReviewStatus.APPROVED.getValue()");
+    }
+
+    @Test
+    void onboardingNotify_mapsRejectedToRejected() {
+        stubOneRow();
+
+        service.handleOnboardingNotify(signed(onboardingBody("2")), "{}");
+
+        assertEquals("REJECTED", row.getReviewStatus(),
+                "本地列写的是 LsyMerchantReviewStatus.REJECTED.getValue()");
+    }
+
+    /**
+     * 未知值 ⇒ <b>不抛、不瞎映射、原样记日志、应答 SUCCESS</b>。
+     *
+     * <p>与「找不到行 ⇒ FAIL」<b>朝向相反,但两条都对</b>:区别是「这次通知该不该再来一次」。
+     * 未知值说明上游<u>确实</u>送到了有效地址、报文也是真的,只是我们还不认识这个值 ——
+     * 应答 FAIL 会让上游无限重推,而重推一万次也变不出一个我们能认的值。
+     * 那条日志是将来补值域的唯一线索,所以必须带**原值**。
+     *
+     * <p>★ 断言「一条 SQL 都没发」是这条的另一半:值都不认识,就没有可写的目标状态。
+     */
+    @Test
+    void onboardingNotify_unknownStatus_stillSucceedsAndLogsRaw() {
+        String unknown = "9";   // 另一套值域(page 196236567)里的「审核驳回」—— 本套不认它
+
+        List<ILoggingEvent> captured = new ArrayList<>();
+        String response = withLogCapture(captured, () ->
+                service.handleOnboardingNotify(signed(onboardingBody(unknown)), "{}"));
+
+        assertEquals(OK, response, "未知值必须应答 SUCCESS,防上游无限重推;实测: " + response);
+        assertTrue(messages(captured).stream().anyMatch(m -> m.contains(unknown)),
+                "未知值必须**原样**(" + unknown + ")进日志 —— 那是将来补值域的唯一线索。实测日志: "
+                        + messages(captured));
+        verify(merchantMapper, never()).selectList(any());
+        verify(merchantMapper, never()).update(any(), any());
+    }
+
+    // ───────────────────────────────── 定位 ─────────────────────────────────
+
+    /**
+     * ★★ 入站键名 ≠ 本地列名:入站读的是 {@code merchant_no},本地列是 {@code mch_no}。
+     *
+     * <p>本用例在报文里放了一个**诱饵** {@code mch_no}(列名那个写法)并给了不同的值,
+     * 然后断言查询绑定的谓词值是 {@code merchant_no} 那个值。
+     * 写成 {@code body.get("mch_no")} 时会去查诱饵 ⇒ 本用例红(真机上症状是
+     * 「每一条通知都走行不存在」)。
+     */
+    @Test
+    void onboardingNotify_locatesByTheInboundKeyMerchantNo() {
+        when(merchantMapper.selectList(any())).thenReturn(List.of());
+
+        Map<String, Object> body = onboardingBody("1");
+        body.put("mch_no", "DECOY-列名那个写法");   // 诱饵:这个值必须**不**被采用
+
+        service.handleOnboardingNotify(signed(body), "{}");
+
+        AbstractWrapper<?, ?, ?> w = capturedSelectList();
+        assertEquals(MCH_NO, boundPredicateValue(w, "mch_no"),
+                "定位谓词必须绑 merchant_no 那个值,实测: " + w.getSqlSegment()
+                        + " / " + w.getParamNameValuePairs());
+    }
+
+    /**
+     * 行不存在 ⇒ 记 ERROR + 应答 FAIL(让上游重推)。
+     *
+     * <p>裁定:这条**不**学交易回调那条「查不到也应答成功、避免无限重推」——
+     * 交易回调查不到是「报文与本地单据对不上」的下游兜底场景,而这里查不到就是
+     * <b>我们这边还没建这一行</b>(或商户号给错了),正是最该让上游再推一次的情况。
+     */
+    @Test
+    void onboardingNotify_missingMerchantRow_returnsFail() {
+        when(merchantMapper.selectList(any())).thenReturn(List.of());
+
+        String response = service.handleOnboardingNotify(signed(onboardingBody("1")), "{}");
+
+        assertEquals(BAD_NOT_FOUND, response, "行不存在必须应答 FAIL,实测: " + response);
+        verify(merchantMapper, never()).update(any(), any());
+    }
+
+    /**
+     * ★★ {@code mch_no} 命中多行 ⇒ 记 ERROR + 应答 FAIL。
+     *
+     * <p><b>这不是「理论上可能重复」</b>:2026-09-23 在真库上实测过 ——
+     * {@code mch_no='Y52211090004'} 有 2 行({@code id} 1 与 2102313081860202497),
+     * 且两行**都合法**(一行 {@code enterprise_id} 有值、一行 NULL,分别落在
+     * {@code uk_lsy_merchant_tenant_ent} 与 {@code uk_lsy_merchant_tenant_level} 上)。
+     * 唯一索引 {@code uk_lsy_merchant_mch_no} **今天不存在**(建不起来,见
+     * {@code db/lsy-merchant-mch-no-uk.sql})。
+     *
+     * <p>所以免登录入口在**没有索引**时靠的是**查询形状**:{@code selectList} 之后自己判,
+     * 多行一律不写。写成 {@code selectOne} + {@code LIMIT 1} 会**静默取任意一行** ——
+     * 无认证查询不带租户谓词,取到的那一行可能是**别的租户**的。
+     *
+     * <p>★ 断言为什么不只写 {@code assertEquals(FAIL, ...)}:那样「多行」与「不存在」两件事
+     * 回同一个串,把定位换成 {@code selectOne}(多行闸没了、退化成「不存在」)**照样绿**。
+     * 所以这里钉的是**回的是哪一条**,并用 {@code never().selectOne} 把形状也钉住。
+     */
+    @Test
+    void onboardingNotify_duplicateMchNo_returnsFail() {
+        LsyMerchantEntity other = new LsyMerchantEntity();
+        other.setId(1L);
+        other.setTenantId(2L);
+        other.setMchNo(MCH_NO);
+        other.setReviewStatus("PENDING");
+        when(merchantMapper.selectList(any())).thenReturn(List.of(row, other));
+
+        String response = service.handleOnboardingNotify(signed(onboardingBody("1")), "{}");
+
+        assertEquals(BAD_AMBIGUOUS, response,
+                "命中多行必须应答**多行**那一条 FAIL(不是「不存在」那一条),实测: " + response);
+        verify(merchantMapper, never()).update(any(), any());
+        verify(merchantMapper, never()).selectOne(any());
+    }
+
+    // ───────────────────────────── 幂等 / 写入口 ─────────────────────────────
+
+    /**
+     * 同一通知重放两次,第二次是 <b>no-op</b>:值与库里相同则不写。
+     *
+     * <p>★ {@code update} 被 {@code thenAnswer} 认领后会**真的**把新值写回那一行
+     * (模拟持久化)—— 所以第二次调用看到的已经是 {@code APPROVED}。
+     * 不做这一步,「重放」在 mock 里根本不存在:第二次仍会写库,而用例看起来照样绿。
+     */
+    @Test
+    void onboardingNotify_isIdempotent() {
+        stubOneRow();
+        Map<String, Object> body = signed(onboardingBody("审核通过"));
+
+        String first = service.handleOnboardingNotify(body, "{}");
+        String second = service.handleOnboardingNotify(body, "{}");
+
+        assertEquals(OK, first);
+        assertEquals(OK, second, "重放也必须应答 SUCCESS(重推不该被当成错误)");
+        verify(merchantMapper, times(1)).update(any(), any());
+    }
+
+    /**
+     * ★ 不得复用 {@code LsyMerchantService.save} —— 它的编辑分支写不了
+     * {@code review_status} / {@code status},返回值又与正常成功无法区分
+     * (回读行里那两列仍是库里的旧值)。这条契约在 {@code save} 的 javadoc 里已经写着。
+     */
+    @Test
+    void onboardingNotify_doesNotWriteThroughSave() {
+        stubOneRow();
+
+        service.handleOnboardingNotify(signed(onboardingBody("1")), "{}");
+
+        verify(merchantService, never()).save(any());
+        verify(merchantMapper, never()).updateById(any());
+        verify(merchantMapper, never()).insert(any());
+    }
+
+    // ───────────────────────────────── 日志安全 ─────────────────────────────────
+
+    /**
+     * 报文里的 {@code \r\n} 不能伪造日志行(CWE-117)。
+     *
+     * <p>本入口免登录,且这些实参在**验签之前**就打(验签失败恰恰是最需要看「上游到底发了什么」
+     * 的时候,不能把日志推到验签之后)⇒ 报文字段是彻底不可信的输入。
+     *
+     * <p>★ 先断言「确实捕获到了事件」:logger 挂错时下面那条「不含换行」的断言会在**空列表**
+     * 上恒真 —— 静默绿,失效方式与它要防的缺陷一样隐蔽。
+     */
+    @Test
+    void onboardingNotify_sanitizesLogInput() {
+        String forged = "\r\n2026-01-01 00:00:00 ERROR 伪造的日志行";
+
+        List<ILoggingEvent> captured = new ArrayList<>();
+        withLogCapture(captured, () -> {
+            // 阶段 1:未验签(签名被伪造)⇒ merchant_no / reviewed_status / rawBody 三处实参
+            Map<String, Object> unsigned = onboardingBody("1" + forged);
+            unsigned.put("merchant_no", MCH_NO + forged);
+            unsigned.put("sign", "00000000000000000000000000000000");
+            service.handleOnboardingNotify(unsigned, "{\"result_code\":\"SUCCESS\"}" + forged);
+
+            // 阶段 2:已验签但值域未知 ⇒ 另一条日志语句(在另一条分支上,不能互相替代)。
+            // ★ 这里**不**给 selectList 打桩:未知值那条分支必须在**碰库之前**就返回 OK
+            //   (见 onboardingNotify_unknownStatus_stillSucceedsAndLogsRaw 的 never() 断言)。
+            //   打了桩反而是一条永不消费的 stubbing,严格模式下会把本用例判红。
+            Map<String, Object> unknownStatus = onboardingBody("9" + forged);
+            unknownStatus.put("merchant_no", MCH_NO + forged);
+            service.handleOnboardingNotify(signed(unknownStatus), "{}" + forged);
+            return null;
+        });
+
+        assertFalse(captured.isEmpty(),
+                "没有捕获到任何日志事件 —— appender 很可能挂错了 logger,本用例已失效");
+        List<String> dirty = messages(captured).stream()
+                .filter(m -> m.contains("\n") || m.contains("\r"))
+                .toList();
+        assertTrue(dirty.isEmpty(),
+                "日志里出现行终止符 ⇒ 未验签报文可伪造日志行(CWE-117);实际=" + dirty);
+    }
+
+    // ───────────────────────────────── 夹具 ─────────────────────────────────
+
+    /**
+     * 上游 page 196236556 的一份请求(未签名)。
+     * 字段名逐个取自该页的**请求参数表**;例外的只有 {@code reviewed_reason}:
+     * 参数表标它**必填=是**,而同页请求示例给的是 {@code null} —— 两种都见过,这里给值。
+     */
+    private Map<String, Object> onboardingBody(String reviewedStatus) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("inst_no", "900024");
+        m.put("merchant_no", MCH_NO);
+        m.put("merchant_name", "super超市");
+        m.put("reviewed_status", reviewedStatus);
+        m.put("reviewed_reason", "资料缺失");
+        m.put("channel_name", "某通道");
+        m.put("mch_id", "3025412121211");
+        return m;
+    }
+
+    private Map<String, Object> signed(Map<String, Object> body) {
+        Map<String, Object> m = new LinkedHashMap<>(body);
+        m.put("sign", signer.sign(m, KEY));
+        return m;
+    }
+
+    /**
+     * 定位到**恰好一行**,并把 {@code update} 接成「真的写回那一行」——
+     * 写回的值从 wrapper 的 SET 子句里读,不是从入参猜的,
+     * 所以「SET 里写的不是 review_status」也会被这一步反映出来。
+     */
+    private void stubOneRow() {
+        when(merchantMapper.selectList(any())).thenReturn(List.of(row));
+        // ★ 必须用 doAnswer(...).when(mock).update(...) 这个形式,不能写
+        //   when(mock.update(any(), any())).thenAnswer(...):
+        //   when(...) 那种写法**会真的调用一次** mock 的方法,于是**上一轮**注册的 Answer
+        //   会在这行里被执行,而此刻两个实参都是 null(匹配器传的就是 null)
+        //   ⇒ setValue(null, ...) 直接 NPE。本轮之前这里是「每个用例只打一次桩」,
+        //   在 acceptsBothStatusShapes 这种**循环里反复打桩**的用例上就会炸。
+        doAnswer(inv -> {
+            row.setReviewStatus((String) setValue(inv.getArgument(1), "review_status"));
+            return 1;
+        }).when(merchantMapper).update(any(), any());
+    }
+
+    private AbstractWrapper<?, ?, ?> capturedSelectList() {
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Wrapper<LsyMerchantEntity>> captor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(merchantMapper).selectList(captor.capture());
+        return (AbstractWrapper<?, ?, ?>) captor.getValue();
+    }
+
+    /** 解析 `列 = ?` 谓词绑定的**值**(MP 只把占位符写进 SQL 文本,真值在参数表里)。 */
+    private static Object boundPredicateValue(AbstractWrapper<?, ?, ?> w, String column) {
+        Matcher m = Pattern.compile(column + " = #\\{ew\\.paramNameValuePairs\\.([A-Za-z0-9_]+)\\}")
+                .matcher(w.getSqlSegment());
+        assertTrue(m.find(), "WHERE 里应有 " + column + " = ? 谓词,实测: " + w.getSqlSegment());
+        return w.getParamNameValuePairs().get(m.group(1));
+    }
+
+    /** 从 `LambdaUpdateWrapper` 的 SET 子句里取某一列的值。 */
+    private static Object setValue(Object wrapper, String column) {
+        LambdaUpdateWrapper<?> w = (LambdaUpdateWrapper<?>) wrapper;
+        Matcher m = Pattern.compile(column + "=#\\{ew\\.paramNameValuePairs\\.([A-Za-z0-9_]+)\\}")
+                .matcher(w.getSqlSet());
+        assertTrue(m.find(), "SET 里应有 " + column + ",实测: " + w.getSqlSet());
+        return w.getParamNameValuePairs().get(m.group(1));
+    }
+
+    private static String json(Map<String, Object> body) {
+        return JSONUtil.toJsonStr(body);
+    }
+
+    private static List<String> messages(List<ILoggingEvent> events) {
+        return events.stream().map(ILoggingEvent::getFormattedMessage).toList();
+    }
+
+    /** 挂 appender、跑 body、**无论成败都摘掉**(不摘会把后续用例的日志也吃进来)。 */
+    private static <T> T withLogCapture(List<ILoggingEvent> sink, Supplier<T> body) {
+        Logger logger = (Logger) LoggerFactory.getLogger(LsyOnboardingNotifyService.class);
+        ListAppender<ILoggingEvent> appender = new ListAppender<>();
+        appender.start();
+        logger.addAppender(appender);
+        try {
+            T result = body.get();
+            sink.addAll(appender.list);
+            return result;
+        } finally {
+            logger.detachAppender(appender);
+        }
+    }
+}