Kaynağa Gözat

feat(进件): 落「已提交」记号 —— 补上「建了号」与「提交了」在库里字面相同那个空档

createOnboardingRow 写 PENDING,审核通知也写 review_status ⇒「建了号还没提交入网」
与「提交了在等审核」在库里长得一模一样,管理端因此答不出「下一步该干嘛」。
这是把进件页改成「列表 + 每行一个下一步按钮」的前提。

本地词表加 SUBMITTED —— 它是本枚举里唯一一个我们自己造的拼写(前三个都是从既有
地方逐字沿用的词),理由写在枚举注释里。它不来自任何上游报文,所以不违反
「不得归一化上游值域」那条禁令:上游那三套值域仍然各是各的。

markSubmitted(mchNo, tenantId) 自己再闸一次,不收调用方刚定位到的那一行:
requireByMchNoForTenant 的契约就是「闸是本方法自己的事,不依赖调用方先检查一遍」。
代价是 /commit 上多一次 SELECT(人工点一次的低频操作),换来的是本方法单独被调用
也安全。定向只写 review_status + updated_time 两列,WHERE 用主键(mch_no
2026-09-23 时还没有唯一索引,按它写会一次改多行)。0 行不静默:记号没落上却回成功,
操作员会以为还要再提交一次,而上游那一次无法回滚。

/commit 的出口加一条护栏:记号写在 commitMerchantInfo 之后,上游那一次提交已经是
既成事实 ⇒ 记号失败不得原样上抛。两种逃逸形态都给出误导性指令:闸的 404 会说
「商户号不存在」(而操作员刚提交成功),持久层异常会落到 GlobalExceptionHandler
的 catch-all = HTTP 500,而 500 的语义就是「重试」。照 /create 那条 409 先例:
说清既成事实 + 禁止直接重试 + 原始异常连同 tenantId/mchNo 进日志。

两向变异实测:
- M-A2(丢掉定位查询里的租户谓词、查询形状不变)⇒ 4 红;其中
  markSubmitted_lookupCarriesTenantPredicate_boundToTheInputTenant 报出
  「定位条件应恰好两条……实测: [(mch_no = #{...})] ⇒ expected: <2> but was: <1>」
  —— 杀它的是断言本身,不是附带原因;
- M-B(把记号挪到上游调用之前)⇒ 新加的三条控制器用例全红,服务侧不受影响
  (变异在控制器,这个「没波及别处」本身也是一条读数);
- M-C(定向 SET 两列换序,无害改动)⇒ 全绿,证明列断言不耦合于子句顺序。
(先用的是 M-A「把 requireByMchNoForTenant 换成 requireByMchNo」,也全红,但杀它的
路径是 requireByMchNo 走 selectOne 而 mock 没铺 ⇒ 附带原因,不足以证明那条断言有牙,
故换 M-A2 重做。)

顺带修两处测试侧问题(都是本轮跑出来的读数,不是推断):
- merchantQuery_carriesExplicitTenantPredicate_evenWhenInterceptorWouldSkip 的 InOrder
  表达不了新形状:/commit 现在有两次 selectList,两次在 mapper 的调用表里连成一块
  ⇒ 默认 times(1) 报「Wanted 1 time but was 2」,换 atLeastOnce 又整块消费、把游标推过
  commitMerchantInfo ⇒ 第二条断言必红(两种都实测过)。改成直接比 Mockito 全局序号。
  同处把「两次查询」逐条断言都带租户谓词 —— 比原来只看第一条更强。
- 我自己新写的 markSubmitted_whereTargetsTheLocatedRowId 把 Matcher.group() 放在第二次
  find() 之后 ⇒ IllegalStateException("No match found")。形状照 save 那条既有用例改回来。

mvn test 668 绿(基线 658,+10 = 服务 7 条 + 控制器 3 条),BUILD SUCCESS。
alphaH 3 hafta önce
ebeveyn
işleme
279aae035a

+ 45 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingController.java

@@ -210,6 +210,10 @@ public class LeshouyingOnboardingController {
      * {@code requireByMchNoForTenant}):请求体里的 {@code mchNo} 是**外部输入**,
      * 不先证明它属于当前租户就把它交给下游,等于允许任何一个登录用户拿别人的商户号
      * 去走提交/查询那条链。
+     *
+     * <p>★ <b>成功出口上还有一步「已提交」记号</b>({@link #markSubmittedOrSayWhy}):
+     * 它填的是「建了号」与「提交了在等」在库里字面相同那个空档,也是管理端答「下一步该干嘛」的
+     * 全部依据。顺序是闸 → 上游 → 记号,三步都不得越位:记号排在上游之前会把失败也标成「审核中」。
      */
     @PreAuthorize("@perm.hasAny('module_payment:leshouying:onboarding:commit')")
     @PostMapping("/commit")
@@ -217,9 +221,50 @@ public class LeshouyingOnboardingController {
         Long tenantId = requireTenantId();
         merchantService.requireByMchNoForTenant(body.getMchNo(), tenantId);
         onboardingService.commitMerchantInfo(body);
+        markSubmittedOrSayWhy(body.getMchNo(), tenantId);
         return Result.ok();
     }
 
+    /**
+     * 落「已提交」记号;失败时**不**让它原样逃逸 —— 上游那一次提交已经是既成事实。
+     *
+     * <p>【为什么要有这一步】{@code createOnboardingRow} 写 {@code PENDING},而审核通知也写
+     * {@code review_status} ⇒ 光看那一列分不出「建了号还没提交」与「提交了在等审核」,
+     * 管理端于是答不出「下一步该干嘛」。记号落在 {@link LsyMerchantService#markSubmitted}。
+     *
+     * <p>【为什么单独一个方法】这段护栏的两个要点(说什么、为什么这么说)都属于它自己,
+     * 塞进 {@link #commit} 会把那个方法的主线(闸 → 上游 → 记号)淹掉。
+     *
+     * <p>【为什么必须换成 409 而不是让它原样上抛】两种逃逸形态都会给出**误导性**的指令:
+     * <ul>
+     *   <li>闸的 404 文案逐字是「商户号不存在」—— 而操作员明明刚把这个商户提交成功;</li>
+     *   <li>持久层异常落到 {@code GlobalExceptionHandler} 的 catch-all ⇒ <b>HTTP 500
+     *       「服务器内部错误」</b>,零信息,而 <b>500 的语义就是「重试」</b>;
+     *       重试一次就是再向上游提交一次,而那次调用无法回滚。</li>
+     * </ul>
+     * 所以这里照 {@link #create} 那条 409 先例:说清既成事实 + 给一句行为指令 + 把原始异常
+     * 连同 {@code tenantId} / {@code mchNo} 打进日志(那是人工对账唯一能用的凭据)。
+     *
+     * <p>★ <b>catch {@code RuntimeException} 而不是某个具体类型</b>:这条路径上的异常形态有两个
+     * 来源 —— 闸自己的 403/400/404/409,以及 MyBatis-Spring 把那个 SQLException 翻成了什么
+     * (同 {@link #create} 的 javadoc:探针实测同一条代码路径上有多个类)。护栏建在其中任何一个上
+     * 都会漏掉另一个。
+     *
+     * <p>★ 文案里不含上游名号 / 域名 / 上游专有键名(Global Constraint 1 —— 它原样回给调用方)。
+     */
+    private void markSubmittedOrSayWhy(String mchNo, Long tenantId) {
+        try {
+            merchantService.markSubmitted(mchNo, tenantId);
+        } catch (RuntimeException e) {
+            log.error("进件「已提交」记号写入失败(上游已受理本次提交,本地状态未更新):"
+                            + "tenantId={}, mchNo={}, 异常={}",
+                    tenantId, LsyLogSanitizer.flatten(mchNo), e.toString(), e);
+            // 文案照 create 那条 409 的先例:说清既成事实 + 给一句行为指令。
+            throw new BusinessException(409, "入网信息已提交(商户号 " + mchNo
+                    + "),但本地状态未同步,不得当作失败直接重试,请联系管理员处理");
+        }
+    }
+
     /**
      * 更新商户信息(上游 {@code /api/merchant/update},**增量**语义:报文里有值的项才改)。
      *

+ 25 - 3
java/src/main/java/com/payment/platform/module/payment/leshouying/enums/LsyMerchantReviewStatus.java

@@ -3,7 +3,8 @@ package com.payment.platform.module.payment.leshouying.enums;
 /**
  * {@code pay_lsy_merchant.review_status} 的**本地词表** —— 我们自己那一列的取值,**不是**上游报文的值域。
  *
- * <p><b>三个字面值的来源,逐条可查(都不是新造词)</b>
+ * <p><b>四个字面值的来源,逐条可查</b> —— 前三条是从既有地方沿用的词,第四条
+ * ({@link #SUBMITTED})是<b>本枚举里唯一一个我们自己造的拼写</b>,理由见该条,不藏着。
  * <ol>
  *   <li>{@link #PENDING} —— <b>本批次(三-B)新引入</b>:进件写入口用它,让刚提交的商户在管理端显示
  *       「待审核」而不是「已通过」。<b>不得</b>让新行落到建表语句的默认值 {@code 'APPROVED'} 上:
@@ -16,8 +17,21 @@ package com.payment.platform.module.payment.leshouying.enums;
  *       该列注释逐字列着 {@code RECEIVED/APPLIED/DUPLICATE/REJECTED/...},而
  *       {@code LsyNotifyService} 在验签失败分支上写的就是字面量 {@code "REJECTED"}。
  *       注意它沿用的是**这个词**,不是那个列的含义 —— 两列语义不同,只是不另造拼写。</li>
+ *   <li>{@link #SUBMITTED} —— <b>新造词(2026-09-23)</b>,为「傻瓜化」补的那个中间态。
+ *       上面三条都能从既有地方逐字指出来源,这一条不能 —— 因为<b>库里那一列从来没有过这个词</b>。</li>
  * </ol>
  *
+ * <p>★ <b>为什么要造 {@code SUBMITTED}(不造的话会怎样)</b>:{@code createOnboardingRow} 写
+ * {@link #PENDING},而审核通知也写同一列 ⇒「建了号、还没提交入网」与「提交了、在等审核」
+ * <b>在库里长得一模一样</b>。管理端于是答不出「下一步该干嘛」,只能让操作员自己去猜 ——
+ * 而这正是本枚举存在的那个空档。造一个词是补这个空档最小的一步:<b>不需要加数据库列</b>
+ * (列宽 {@code VARCHAR(32)} 装得下),不需要动上游协议。
+ *
+ * <p>★ 它为什么<b>不</b>违反「不得归一化上游值域」那条禁令(见类 javadoc 上半段):这个词
+ * <b>不来自任何上游报文</b>。它是我们自己库里那一列的取值,由我们自己的写入口
+ * ({@code LsyMerchantService.markSubmitted})写下,之后被上游审核通知覆盖成
+ * {@link #APPROVED} / {@link #REJECTED}。上游那一侧的三套值域仍然是三套,一个都没合并。
+ *
  * <p>★ <b>这不是 spec §6.4 禁止的那种「上游值域归一化」。</b>那条禁的是把上游**三套**
  * {@code reviewed_status} 值域(异步通知 page 196236556、入网状态 page 196236564、主动查询
  * page 196236567)猜着合并成一个枚举 —— 三套各自实现、各按各的解析,**不做归一化猜测**。
@@ -27,13 +41,21 @@ package com.payment.platform.module.payment.leshouying.enums;
  *
  * <p>★ <b>写库时用 {@link #getValue()},不要用 {@code name()}</b>:字面值是**列契约**的一部分
  * (列宽 {@code VARCHAR(32)}、管理端按它显示、上游通知回写时按它比对),改常量名<b>不得</b>
- * 顺带改掉落进列里的那个词。三个字面值有测试钉着({@code LsyMerchantReviewStatusTest})。
+ * 顺带改掉落进列里的那个词。四个字面值有测试钉着({@code LsyMerchantReviewStatusTest})。
  */
 public enum LsyMerchantReviewStatus {
 
-    /** 进件已提交、等待上游审核 —— 三-B 的进件写入口写这个值。 */
+    /** 建了号、资料也填了,但**还没提交入网** —— 三-B 的进件写入口写这个值。 */
     PENDING("PENDING"),
 
+    /**
+     * 已提交入网、等待上游审核 —— {@code markSubmitted} 写这个值。
+     *
+     * <p>它填的是 {@link #PENDING} 与上游审核结果之间那个空档:光看 {@code PENDING}
+     * 分不出「还没提交」和「提交了在等」,管理端因此答不出下一步该干嘛。见类 javadoc。
+     */
+    SUBMITTED("SUBMITTED"),
+
     /** 审核通过 —— 沿用建表语句默认值 {@code 'APPROVED'} 里的那个词。 */
     APPROVED("APPROVED"),
 

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

@@ -394,6 +394,61 @@ public class LsyMerchantService {
         return row;
     }
 
+    /**
+     * 进件的**「已提交」记号**:把 {@code review_status} 定向写成
+     * {@link LsyMerchantReviewStatus#SUBMITTED},让管理端分得出「建了号还没提交」与「提交了在等审核」。
+     *
+     * <p><b>为什么必须有这一步:</b>{@link #createOnboardingRow} 写 {@code PENDING},而审核通知
+     * ({@link #updateReviewStatusByMchNo})也写同一列 ⇒ 那一列上「建了号」与「提交了」两个字面
+     * **完全相同**,系统因此答不出「下一步该干嘛」。本方法是补这个空档最小的一步
+     * (不加列、不动上游协议 —— 理由逐条写在 {@link LsyMerchantReviewStatus#SUBMITTED})。
+     *
+     * <p>★ <b>为什么自己再闸一次,而不是收调用方刚定位到的那一行:</b>
+     * {@link #requireByMchNoForTenant} 的契约是「闸是本方法自己的事,不依赖调用方先检查一遍」。
+     * 改成收一个已定位的实体当参数,等于把那条契约挪到调用方身上 —— 于是「忘了先闸」与
+     * 「闸过了」在类型上长得一模一样,而代价是改**别人的**行。代价是这条腿多一次 SELECT
+     * (人工点一次的低频操作),换来的是本方法<b>单独被调用也安全</b>。
+     *
+     * <p>★ <b>为什么 0 行不静默:</b>记号没落上却回「成功」⇒ 操作员看到进度还停在「待提交入网」
+     * ⇒ 再点一次「提交入网」⇒ 上游又收到一次提交,而那次调用<b>无法回滚</b>。宁可响亮失败。
+     * (走到这里说明定位与写入之间那一行被删了 —— 闸刚刚才按主键定位到它。)
+     *
+     * <p>★ 定向写 {@code review_status} + {@code updated_time} 两列,照
+     * {@link #updateReviewStatusByMchNo} 与 {@code save} 编辑分支的既有先例:<b>不</b>走
+     * {@code updateById}({@code NOT_NULL} 策略会把实体上所有非 null 列都塞进 SET),
+     * {@code WHERE} 用**主键**而不是 {@code mch_no}(后者会一次改多行 —— 该列 2026-09-23 时
+     * 还没有唯一索引,见 {@link #requireByMchNo} 的 javadoc)。
+     *
+     * <p>★ <b>刻意不做幂等短路</b>(不像 {@link #updateReviewStatusByMchNo} 那样「值已相同就不写」):
+     * 走到本方法意味着**上游刚刚又收到了一次提交**,那么「审核中」就是如实的当下状态 ——
+     * 哪怕它上一个状态已经是 {@code SUBMITTED}(重复点击),或审核已通过而这次重提把它送回审核。
+     * 短路省下的那一次 UPDATE 换不来任何正确性,却会让 {@code updated_time} 落后于事实。
+     *
+     * @param mchNo    本地列 {@code mch_no} 的值
+     * @param tenantId **必须来自认证上下文**(为 {@code null} ⇒ 403,且一次库都不碰)
+     * @throws BusinessException 403 / 400 / 404 / 409 —— 前四种由 {@link #requireByMchNoForTenant} 抛出;
+     *                           404 还可能是「定位之后该行消失」(那条会先打一条带 id 的 ERROR 日志)
+     */
+    public void markSubmitted(String mchNo, Long tenantId) {
+        LsyMerchantEntity located = requireByMchNoForTenant(mchNo, tenantId);
+
+        // 照 save 编辑分支那个既有先例:定向 SET,不经实体。
+        // ★ updated_time 必须显式写:update(null, wrapper) 不经过实体,拿不到 AutoFillMetaObjectHandler
+        //   的 strictUpdateFill(同 save 与 updateReviewStatusByMchNo 那两处)。
+        LambdaUpdateWrapper<LsyMerchantEntity> w = new LambdaUpdateWrapper<LsyMerchantEntity>()
+                .eq(LsyMerchantEntity::getId, located.getId())
+                .set(LsyMerchantEntity::getReviewStatus, LsyMerchantReviewStatus.SUBMITTED.getValue())
+                .set(LsyMerchantEntity::getUpdatedTime, OffsetDateTime.now());
+        int updated = merchantMapper.update(null, w);
+        if (updated == 0) {
+            // 定位与写入之间该行被删。带 id 是因为那是人工处置唯一能用的线索。
+            log.error("进件「已提交」记号回写命中 0 行(刚刚定位到、写入时已不在): "
+                            + "tenantId={}, mchNo={}, id={}",
+                    tenantId, LsyLogSanitizer.flatten(mchNo), located.getId());
+            throw new BusinessException(404, "商户号不存在: " + mchNo);
+        }
+    }
+
     public PageResult<LsyMerchantEntity> page(int pageNo, int pageSize, String keyword) {
         Page<LsyMerchantEntity> page = new Page<>(pageNo, pageSize);
         LambdaQueryWrapper<LsyMerchantEntity> w = new LambdaQueryWrapper<>();

+ 154 - 9
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingControllerTest.java

@@ -33,6 +33,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.ArgumentCaptor;
 import org.mockito.InOrder;
 import org.mockito.Mock;
+import org.mockito.invocation.Invocation;
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.springframework.core.annotation.AnnotatedElementUtils;
 import org.springframework.dao.DataIntegrityViolationException;
@@ -687,21 +688,131 @@ class LeshouyingOnboardingControllerTest {
         // 臂 ②:同为主体超管,给出显式租户 ⇒ 查询仍自带 tenant_id 谓词
         login(TENANT, true);
         when(mapper.selectList(any())).thenReturn(List.of(row(MCH_NO)));
+        when(mapper.update(any(), any())).thenReturn(1); // 「已提交」记号那次定向写入
         c.commit(commitBody(MCH_NO));
 
-        AbstractWrapper<?, ?, ?> w = capturedSelectList(mapper);
-        assertTrue(w.getSqlSegment().contains("tenant_id = #{"),
-                "查询必须自带 tenant_id 谓词(超管主体下拦截器不会追加)。实测: " + w.getSqlSegment());
-        assertTrue(w.getParamNameValuePairs().containsValue(TENANT),
-                "谓词占位符必须绑定上下文里的租户 " + TENANT + "。实测: " + w.getParamNameValuePairs());
+        // ★ {@code /commit} 上现在有**两次** selectList:闸一次、{@code markSubmitted} 自己再闸一次
+        // (它刻意不依赖调用方已经闸过 —— 见该方法的 javadoc)。本用例问的是「谓词是不是被调方
+        // 自己构造的」,两次都得是,所以**逐条**断言,而不是只看第一条。
+        List<AbstractWrapper<?, ?, ?>> queries = allSelectLists(mapper);
+        assertEquals(2, queries.size(),
+                "本用例的前提:/commit 上有两次定位查询(闸 + 记号各一次)。实测 " + queries.size() + " 次");
+        for (AbstractWrapper<?, ?, ?> w : queries) {
+            assertTrue(w.getSqlSegment().contains("tenant_id = #{"),
+                    "查询必须自带 tenant_id 谓词(超管主体下拦截器不会追加)。实测: " + w.getSqlSegment());
+            assertTrue(w.getParamNameValuePairs().containsValue(TENANT),
+                    "谓词占位符必须绑定上下文里的租户 " + TENANT + "。实测: " + w.getParamNameValuePairs());
+        }
 
         // 臂 ③:闸必须**先于**上游调用。顺序反了(先 commitMerchantInfo 再定位)不是「同样安全」——
         // 那等于拿一个**还没证明属于本租户**的商户号去发起上游提交,403 只是发晚了。
-        // 用 InOrder 钉住:selectList(闸的查询)必须排在 commitMerchantInfo(上游调用)之前。
         // ★ 2026-09-23 实测:本条断言加上之前,把这两行对调**全类仍绿**(读数见 task-7-report.md)。
-        InOrder gateFirst = inOrder(mapper, onboardingService);
-        gateFirst.verify(mapper).selectList(any());
-        gateFirst.verify(onboardingService).commitMerchantInfo(any());
+        //
+        // ★ 为什么这里**不用 InOrder**(2026-09-23 本轮改的,原先就是它):{@code /commit} 现在有
+        //   **两次** selectList(闸一次、记号自己再闸一次,见 {@link LsyMerchantService#markSubmitted}),
+        //   两次在 mapper 的调用表里连成一块 ⇒ InOrder 的默认 times(1) 报
+        //   「Wanted 1 time but was 2」,换 atLeastOnce 又**整块消费**、把游标推过
+        //   commitMerchantInfo ⇒ 第二条断言必红。两种都实测过。
+        //   改成直接比 Mockito 发的全局序号:它问的正是要断言的那件事,且对「查询了几次」不敏感。
+        int gateSeq = firstSequence("selectList", mapper);
+        int upstreamSeq = firstSequence("commitMerchantInfo", onboardingService);
+        assertTrue(gateSeq < upstreamSeq,
+                "闸的定位查询必须排在上游调用之前(实测序号 " + gateSeq + " vs " + upstreamSeq + ")");
+    }
+
+    // ──────────────── 「已提交」记号(2026-09-23 傻瓜化) ────────────────
+
+    /**
+     * ★ {@code /commit} 成功之后必须落「已提交」记号,且**租户取自认证上下文**。
+     *
+     * <p>【为什么这个记号是必需的】{@code createOnboardingRow} 写 {@code PENDING},而审核通知也写
+     * {@code review_status} ⇒ 光看这一列分不出「建了号还没提交」与「提交了在等审核」。
+     * 不落这个记号,管理端就只能让操作员自己猜下一步该干嘛。
+     *
+     * <p>【顺序也是断言的一部分】记号必须排在 {@code commitMerchantInfo} **之后**:
+     * 上游提交都没成功就先把本地写成「审核中」,那是一条**假状态** —— 而它恰好长得跟真的一样。
+     */
+    @Test
+    void commitEndpoint_marksSubmitted_withTenantFromSecurityContext() {
+        login(TENANT, false);
+
+        controller.commit(commitBody(MCH_NO));
+
+        // 租户来自上下文(不是请求体):与闸同一个值,记号才落在同一本账上
+        verify(merchantService).markSubmitted(MCH_NO, TENANT);
+
+        InOrder afterUpstream = inOrder(onboardingService, merchantService);
+        afterUpstream.verify(onboardingService).commitMerchantInfo(any());
+        afterUpstream.verify(merchantService).markSubmitted(any(), any());
+    }
+
+    /**
+     * 上游提交**失败**时一行都不许标 —— 负向对照上面那条。
+     *
+     * <p>少了这条,把 {@code markSubmitted} 挪到 {@code commitMerchantInfo} **之前**
+     * (或放进 finally)也能全绿,而那会让每一次失败的上游调用都在库里留下「审核中」。
+     */
+    @Test
+    void commitEndpoint_upstreamFailure_doesNotMarkSubmitted() {
+        login(TENANT, false);
+        // ★ 必须 doThrow 不能 when:commitMerchantInfo 返回 void(when(void 调用) 编译不过 —— 本次实测)
+        doThrow(new LeshouyingApiException("HTTP_504", "gateway timeout", "{}"))
+                .when(onboardingService).commitMerchantInfo(any());
+
+        assertThrows(LeshouyingApiException.class, () -> controller.commit(commitBody(MCH_NO)));
+
+        verify(merchantService, never()).markSubmitted(any(), any());
+    }
+
+    /**
+     * ★★ 上游已受理、**本地记号没写上**时,出口必须说清既成事实并**禁止重复提交**。
+     *
+     * <p>【为什么这条出口必须存在】记号那一次调用在 {@code commitMerchantInfo} **之后**,
+     * 上游那一次提交是**既成事实**。没有护栏时它会原样逃逸:闸的 404 会说「商户号不存在」
+     * (而操作员明明刚提交成功)、数据库抖动会落到 {@code GlobalExceptionHandler} 的
+     * catch-all ⇒ <b>HTTP 500「服务器内部错误」</b> —— 而 500 的语义就是「重试」,
+     * 重试就是**再向上游提交一次**。
+     *
+     * <p>形状照同文件 {@code createEndpoint_localPersistFailure_saysUpstreamMerchantCreated_andForbidsRetry}
+     * 那条先例:{@code assertAll} 的标题带上异常形态,红的时候能直接看出是哪一种漏了。
+     * 四种形态都要覆盖 —— 前面两条来自闸自己的分支,后面两条来自持久层,
+     * 只堵其中一类会漏掉另一类。
+     */
+    @Test
+    void commitEndpoint_localMarkerFailure_saysSubmitted_andForbidsResubmit() throws Exception {
+        login(TENANT, false);
+
+        List<RuntimeException> shapes = List.of(
+                new BusinessException(404, "商户号不存在: " + MCH_NO),
+                new BusinessException(409, "该商户号在您的账户下不唯一,无法确定对应商户,请联系管理员处理"),
+                new DataIntegrityViolationException("connection reset by peer"),
+                new IllegalStateException("既不属 MyBatis 也不属 Spring 的形态"));
+
+        for (RuntimeException shape : shapes) {
+            LsyMerchantService merchant = mock(LsyMerchantService.class);
+            LsyOnboardingService onboarding = mock(LsyOnboardingService.class);
+            MockMvc mvc = standaloneMvc(new LeshouyingOnboardingController(merchant, onboarding));
+            doThrow(shape).when(merchant).markSubmitted(any(), any());
+
+            assertAll(shape.getClass().getSimpleName() + ": " + shape.getMessage(), () ->
+                    mvc.perform(post(PREFIX + "/commit")
+                                    .contentType(MediaType.APPLICATION_JSON)
+                                    .content("{\"mch_no\":\"" + MCH_NO + "\"}"))
+                            // 出口形态:本仓业务失败的既有出口是 HTTP 200 + 业务码。
+                            // 掉回 catch-all 的话这两个断言会一起红(HTTP 500 / 业务码 500)。
+                            .andExpect(status().isOk())
+                            .andExpect(jsonPath("$.code").value(409))
+                            // 商户号是这条出口的全部价值:操作员靠它去人工对账
+                            .andExpect(jsonPath("$.msg", containsString(MCH_NO)))
+                            .andExpect(jsonPath("$.msg", containsString("不得当作失败直接重试")))
+                            // 闸那句「商户号不存在」不得穿过来 —— 它在这条路径上是假话
+                            .andExpect(jsonPath("$.msg", not(containsString("商户号不存在"))))
+                            // Global Constraint 1:应答里不得有上游痕迹
+                            .andExpect(jsonPath("$.msg", not(containsString("乐收赢")))));
+
+            // 前提(也是这条出口存在的全部原因):上游那一次提交**已经**发生
+            verify(onboarding).commitMerchantInfo(any());
+        }
     }
 
     // ──────────────── category 的线上契约(加记 34) ────────────────
@@ -1985,6 +2096,40 @@ class LeshouyingOnboardingControllerTest {
         return new LeshouyingOnboardingController(new LsyMerchantService(mapper), onboardingService);
     }
 
+    /**
+     * 某个 mock 上某个方法**第一次**被调用的全局序号(Mockito 按真实调用先后发连续号,
+     * 跨 mock 可比 —— 这正是 {@code InOrder} 在「同一方法被调用多次」时表达不了的那个量)。
+     *
+     * <p>方法一次都没被调用过 ⇒ 直接抛断言错(而不是回一个默认值):
+     * 那种情况下「顺序」这个问题本身就不成立,静默给 0 会让下面的大小比较变成一句空话。
+     */
+    private static int firstSequence(String methodName, Object mock) {
+        return mockingDetails(mock).getInvocations().stream()
+                .filter(i -> methodName.equals(i.getMethod().getName()))
+                .mapToInt(Invocation::getSequenceNumber)
+                .min()
+                .orElseThrow(() -> new AssertionError(
+                        mock.getClass().getSimpleName() + " 上根本没被调用过: " + methodName));
+    }
+
+    /**
+     * 取**全部** {@code selectList} 调用({@link #capturedSelectList} 是 {@code times(1)},
+     * 在一条腿上有两次定位查询时会红)。
+     *
+     * <p>用 {@code atLeastOnce} 而不是写死次数:写死次数会让「以后多一次查询」变成一次假红,
+     * 而本方法要回答的问题(每次查询都自带租户谓词吗)与次数无关。
+     */
+    private static List<AbstractWrapper<?, ?, ?>> allSelectLists(LsyMerchantMapper mapper) {
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Wrapper<LsyMerchantEntity>> captor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(mapper, atLeastOnce()).selectList(captor.capture());
+        List<AbstractWrapper<?, ?, ?>> out = new ArrayList<>();
+        for (Wrapper<LsyMerchantEntity> w : captor.getAllValues()) {
+            out.add((AbstractWrapper<?, ?, ?>) w);
+        }
+        return out;
+    }
+
     private static AbstractWrapper<?, ?, ?> capturedSelectList(LsyMerchantMapper mapper) {
         @SuppressWarnings("unchecked")
         ArgumentCaptor<Wrapper<LsyMerchantEntity>> captor = ArgumentCaptor.forClass(Wrapper.class);

+ 12 - 3
java/src/test/java/com/payment/platform/module/payment/leshouying/enums/LsyMerchantReviewStatusTest.java

@@ -19,14 +19,23 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
 class LsyMerchantReviewStatusTest {
 
     /**
-     * 三个字面值 = 建表语句默认值里的 {@code APPROVED} + 同模块 {@code process_status} 已在用的
-     * {@code REJECTED} + 本批次新引入的 {@code PENDING}(列宽 {@code VARCHAR(32)},
-     * 三个词各自都短于列宽 —— 这条不用断言,它由 DDL 决定)。
+     * 四个字面值 = 建表语句默认值里的 {@code APPROVED} + 同模块 {@code process_status} 已在用的
+     * {@code REJECTED} + 本批次新引入的 {@code PENDING},以及
+     * {@code SUBMITTED}(2026-09-23 为「傻瓜化」新增的**本地中间态**,见下)。
+     * (列宽 {@code VARCHAR(32)},四个词各自都短于列宽 —— 这条不用断言,它由 DDL 决定。)
+     *
+     * <p>★ <b>{@code SUBMITTED} 为什么必须存在</b>:{@code createOnboardingRow} 写
+     * {@code PENDING},而审核通知也写这一列 ⇒「建了号还没提交入网」与「提交了在等审核」
+     * 在库里**长得一模一样**,管理端因此答不出「下一步该干嘛」。它就是那个空档的填法。
+     *
+     * <p>★ 它**不是**上游报文的值域(本枚举的类注释已逐字划清这条界线):它是我们库里那一列
+     * 的取值,由我们自己的写入口写下,等上游审核通知到了被覆盖成 {@code APPROVED} / {@code REJECTED}。
      */
     @Test
     void value_isTheWordThatLandsInTheColumn() {
         assertEquals("PENDING", LsyMerchantReviewStatus.PENDING.getValue());
         assertEquals("APPROVED", LsyMerchantReviewStatus.APPROVED.getValue());
         assertEquals("REJECTED", LsyMerchantReviewStatus.REJECTED.getValue());
+        assertEquals("SUBMITTED", LsyMerchantReviewStatus.SUBMITTED.getValue());
     }
 }

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

@@ -305,6 +305,200 @@ class LsyMerchantServiceTest {
         verify(merchantMapper, never()).insert(any());
     }
 
+    // ────────────────── markSubmitted():进件「已提交」记号(2026-09-23 傻瓜化) ──────────────────
+
+    /**
+     * 定向写 {@code review_status} + {@code updated_time},**恰好这两列**,值就是 {@code "SUBMITTED"}。
+     *
+     * <p>★ 为什么这个记号必须有:{@code createOnboardingRow} 写 {@code PENDING},而审核通知也写
+     * {@code review_status} ⇒「建了号还没提交入网」与「提交了在等审核」在库里**长得一模一样**,
+     * 管理端因此答不出「下一步该干嘛」。本方法就是那个空档的填法。
+     *
+     * <p>★ 为什么是集合相等而不是 contains:多一列意味着覆盖了别的列({@code save} 那条 D-1
+     * 静默数据缺陷正是这一形态),少一列意味着记号没写上。{@code status}(启用/停用)与
+     * {@code review_status} 是两件事,前者不得被本方法碰。
+     */
+    @Test
+    void markSubmitted_writesExactlyTheTwoColumns_withSubmittedWord() {
+        when(merchantMapper.selectList(any()))
+                .thenReturn(List.of(rowWithReviewStatus(EDIT_ID, "PENDING")));
+        when(merchantMapper.update(any(), any())).thenReturn(1);
+
+        service.markSubmitted("Y52211090004", 7L);
+
+        LambdaUpdateWrapper<?> w = capturedUpdate();
+        Set<String> cols = setColumns(w);
+        assertEquals(Set.of("review_status", "updated_time"), cols,
+                "SET 必须恰好是这两列。实测: " + w.getSqlSet());
+        assertFalse(cols.contains("status"),
+                "status 是启用/停用,不在本方法的职责里(同一个 contains 子串陷阱:review_status 含 status)");
+        assertFalse(cols.contains("mch_no"), "mch_no 只是定位条件,不得进 SET");
+        assertFalse(cols.contains("tenant_id"), "tenant_id 不得进 SET");
+        assertFalse(cols.contains("id"), "id 只是 WHERE 条件,不得进 SET");
+
+        // 写进去的是 getValue() 那个词,不是枚举常量名恰好相同就放行 —— 字面值由枚举自己的用例钉住
+        assertEquals("SUBMITTED", setValue(w, "review_status"),
+                "实测 SET 参数表: " + w.getParamNameValuePairs());
+
+        // updated_time 必须显式写:update(null, wrapper) 不经过实体,拿不到 AutoFill 的 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());
+    }
+
+    /**
+     * ★★ 本方法最要紧的一条:**定位查询必须带租户谓词**。
+     *
+     * <p>同文件里有一个形状几乎一样、名字也像的 {@code updateReviewStatusByMchNo} ——
+     * 它是**免登录**入口,按 {@code mch_no} 跨租户 {@code selectList}。顺手复用它(或复用它调用的
+     * {@code requireByMchNo})会让本方法变成:任何登录用户拿别人的商户号点一下「提交入网」,
+     * 就能把**别的租户**那一行的状态改掉。这条用例就是拦这个的 —— 条件集必须恰好是
+     * ({@code tenant_id} + {@code mch_no})两条,且租户谓词绑的是入参租户。
+     */
+    @Test
+    void markSubmitted_lookupCarriesTenantPredicate_boundToTheInputTenant() {
+        when(merchantMapper.selectList(any()))
+                .thenReturn(List.of(rowWithReviewStatus(EDIT_ID, "PENDING")));
+        when(merchantMapper.update(any(), any())).thenReturn(1);
+
+        service.markSubmitted("Y52211090004", 7L);
+
+        AbstractWrapper<?, ?, ?> q = capturedSelectList();
+        String seg = q.getSqlSegment();
+        Set<String> conds = Arrays.stream(seg.replaceAll("[()]", "").split(" AND "))
+                .map(String::trim).collect(Collectors.toSet());
+
+        assertEquals(2, conds.size(), "定位条件应恰好两条(租户 + 商户号)。实测: [" + seg + "] ⇒ " + conds);
+        assertEquals(1, conds.stream().filter(c -> c.startsWith("tenant_id = ")).count(),
+                "必须恰有一条租户条件(少了就跨租户改别人的行)。实测条件: " + conds);
+        assertTrue(conds.stream().anyMatch(c -> c.startsWith("mch_no = ")),
+                "必须按 mch_no 定位。实测条件: " + conds);
+        assertEquals(7L, boundPredicateValue(q, "tenant_id"),
+                "租户谓词应绑定入参 tenantId。实测: " + q.getParamNameValuePairs());
+    }
+
+    /**
+     * 写入的 WHERE 指向**定位到那一行的 id**,不是 {@code mch_no}。
+     *
+     * <p>同 {@code updateReviewStatusByMchNo_whereTargetsTheLocatedRowId} 的理由:定位与写入之间
+     * 若有人插进一行同 {@code mch_no} 的行(唯一索引 2026-09-23 时还没建),
+     * {@code WHERE mch_no = ?} 会一次改两行。主键定位没有这个窗口。
+     */
+    @Test
+    void markSubmitted_whereTargetsTheLocatedRowId() {
+        when(merchantMapper.selectList(any()))
+                .thenReturn(List.of(rowWithReviewStatus(EDIT_ID, "PENDING")));
+        when(merchantMapper.update(any(), any())).thenReturn(1);
+
+        service.markSubmitted("Y52211090004", 7L);
+
+        LambdaUpdateWrapper<?> w = capturedUpdate();
+        String where = w.getSqlSegment();
+        assertTrue(where.startsWith("(") && where.endsWith(")"),
+                "WHERE 应是一个带括号的等值条件(删掉 .eq 那行时实测为空串)。实测: [" + where + "]");
+        String inner = where.substring(1, where.length() - 1);
+        assertTrue(inner.contains(" = "), "WHERE 里应出现 ` = ` 分隔符。实测: [" + where + "]");
+        assertEquals("id", inner.substring(0, inner.indexOf(" = ")),
+                "定位列应是 id(换成 mch_no 时这里会报出那个列名)。实测: [" + where + "]");
+        assertEquals(inner.indexOf(" = "), inner.lastIndexOf(" = "),
+                "WHERE 里应恰有一处等值条件(两处以上 ⇒ 多了别的定位条件)。实测: [" + where + "]");
+
+        Matcher ph = Pattern.compile("MPGENVAL\\d+").matcher(inner);
+        assertTrue(ph.find(), "WHERE 里应有一个占位符。实测: [" + where + "]");
+        // ★ 先把组名取出来再找第二次:第二次 find() 成功之后 matcher 已越过末尾,
+        //   此时再调 group() 抛 IllegalStateException("No match found") —— 已实测。
+        //   形状照 save_updateBranch_whereTargetsExactlyTheInputId(那里就是这个顺序)。
+        String name = ph.group();
+        assertFalse(ph.find(), "WHERE 里应恰有一个占位符。实测: [" + where + "]");
+        assertEquals(Long.valueOf(EDIT_ID), w.getParamNameValuePairs().get(name),
+                "WHERE 绑的应是定位到那一行的 id,实测: " + w.getParamNameValuePairs());
+    }
+
+    /**
+     * 租户闸先于一切:{@code tenantId == null} ⇒ 403,且**一次库都不碰**。
+     *
+     * <p>用 {@code verifyNoInteractions} 而不是只查异常码 —— 后者在「先查了库再抛 403」的写法下
+     * 照样绿,而那种写法已经把别租户的行读进内存了(同 Task 7 那条顺序断言的教训)。
+     */
+    @Test
+    void markSubmitted_nullTenant_throws403_andTouchesNothing() {
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.markSubmitted("Y52211090004", null));
+
+        assertEquals(403, ex.getCode(), "实测: " + ex.getCode());
+        verifyNoInteractions(merchantMapper);
+    }
+
+    /** 本租户内查不到 ⇒ 404,且**一行都不写**(不是「静默成功」—— 记号没落上就不能算提交成功)。 */
+    @Test
+    void markSubmitted_zeroRows_throws404_andWritesNothing() {
+        when(merchantMapper.selectList(any())).thenReturn(List.of());
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.markSubmitted("Y52211090004", 7L));
+
+        assertEquals(404, ex.getCode(), "实测: " + ex.getCode());
+        verify(merchantMapper, never()).update(any(), any());
+    }
+
+    /**
+     * 本租户内命中多行 ⇒ 409,且**一行都不写**。
+     *
+     * <p>这不是理论情形:真库里 {@code mch_no='Y52211090004'} 就有 2 行(见
+     * {@link #requireByMchNoForTenant_duplicateMchNoSameTenant_failsLoudly})。
+     * 挑一行改等于赌哪一行才对,而这个记号是给操作员看的 —— 改错行等于告诉他「提交成功了」,
+     * 而真正那一行还停在「待提交」。
+     */
+    @Test
+    void markSubmitted_multipleRows_throws409_andWritesNothing() {
+        when(merchantMapper.selectList(any())).thenReturn(List.of(
+                rowWithReviewStatus(1L, "PENDING"),
+                rowWithReviewStatus(2102313081860202497L, "PENDING")));
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.markSubmitted("Y52211090004", 7L));
+
+        assertEquals(409, ex.getCode(), "实测: " + ex.getCode());
+        verify(merchantMapper, never()).update(any(), any());
+    }
+
+    /**
+     * 定位到了行、但 UPDATE 影响 0 行(定位与写入之间那一行被删)⇒ 404,**不是静默成功**。
+     *
+     * <p>★ 为什么不能静默:记号没落上却回「成功」,操作员看到进度仍是「待提交入网」⇒ 再点一次
+     * 「提交入网」⇒ **上游又收到一次提交**。而这条路径的上游调用无法回滚。
+     *
+     * <p>这条与上面 {@code zeroRows} 那条(闸里 0 行)是**两个不同的点**,mock 形状也不同:
+     * 那条是 {@code selectList} 返回空,这条是 {@code selectList} 有行而 {@code update} 返回 0。
+     * 形状照 {@code updateReviewStatusByMchNo_updateAffectsZeroRows_returnsNotFound_withoutThrowing}。
+     */
+    @Test
+    void markSubmitted_updateAffectsZeroRows_throws404_insteadOfSilentSuccess() {
+        when(merchantMapper.selectList(any()))
+                .thenReturn(List.of(rowWithReviewStatus(EDIT_ID, "PENDING")));
+        when(merchantMapper.update(any(), any())).thenReturn(0);
+
+        List<String> logs = logsOf(() -> {
+            BusinessException ex = assertThrows(BusinessException.class,
+                    () -> service.markSubmitted("Y52211090004", 7L));
+            assertEquals(404, ex.getCode(), "实测: " + ex.getCode());
+        });
+
+        String all = String.join("\n", logs);
+        assertTrue(all.contains("Y52211090004"),
+                "0 行时 ERROR 日志必须带商户号(人工处置唯一线索)。实测日志: " + all);
+        assertTrue(all.contains(String.valueOf(EDIT_ID)),
+                "日志还须带定位到的 id(那一行就是被删掉的那一行)。实测日志: " + all);
+    }
+
     // ─────────────────────── save():编辑分支(D-1 / D-2 / 0 行判据) ───────────────────────
 
     @Test