From 3773bc4f2d966fb248f96c7e5cedce58c91df689 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期一, 12 十月 2026 15:27:42 +0800
Subject: [PATCH] fix(merchant): 纳管前把申请单号与商户号配一次对,错号当场拒绝

---
 talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java        |    2 
 docs/adr/0006-managed-onboarding-is-query-only.md                                                           |    6 +++
 talent-card-server/src/test/java/com/talentcard/server/business/admin/MerchantManagedOnboardingTests.java   |   49 ++++++++++++++++++++----
 talent-card-admin/src/views/MerchantSettlementView.vue                                                      |    6 ++
 talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantManagedOnboardingService.java |   37 +++++++++++++++++-
 5 files changed, 88 insertions(+), 12 deletions(-)

diff --git a/docs/adr/0006-managed-onboarding-is-query-only.md b/docs/adr/0006-managed-onboarding-is-query-only.md
index 0432279..0938632 100644
--- a/docs/adr/0006-managed-onboarding-is-query-only.md
+++ b/docs/adr/0006-managed-onboarding-is-query-only.md
@@ -24,6 +24,12 @@
 名称改成「展示 + 运营显式勾选确认 + 写进进件记录留痕 + 可解绑」,并在两个名称与商家名一个字符都不重叠时
 给出醒目警示但**不硬拒**。
 
+**确定性的事里还包括「申请单号必须属于这个商户号」。** 商户号与入驻申请单号是两个分开填的字段,通道对它们
+是**一起**校验的:填错的号——包括"真实存在、但属于别的商户号"的号——都回 `00000001 申请单不存在`
+(2026-10-12 实测两种)。所以建记录之前先配一次对,错号当场拒绝。不拦的代价不是误放(错号会让基础审核
+永远确认不了、闸门一直拒),而是留下一条收不了款、且刷新只报「汇付查询未成功」这种误导文案的记录,
+而重复纳管幂等、改不了号,只能解绑重来。
+
 **解绑只解除我方绑定。** 它清理这条纳管产生的进件记录与结算卡记录(商户端的收款权限是从进件记录派生的,
 记录没了权限也就没了),但**不回退通道侧的任何改动**——微信口径、人才端 AppID 绑定、延迟入账、实名都不是
 纳管改的,解绑也不会把它们撤销。该商家名下还有在途订单或待结算资金时拒绝解绑,避免切断正在进行的对账。
diff --git a/talent-card-admin/src/views/MerchantSettlementView.vue b/talent-card-admin/src/views/MerchantSettlementView.vue
index e52dd6d..aec723c 100644
--- a/talent-card-admin/src/views/MerchantSettlementView.vue
+++ b/talent-card-admin/src/views/MerchantSettlementView.vue
@@ -266,9 +266,12 @@
   }
   adoptPreviewing.value = true
   try {
+    // 申请单号一起送去核验:错号(含"真实存在但属于别的商户号")在通道侧会回「申请单不存在」,
+    // 后端会当成硬拦截项列出来,运营当场就知道填错了,不用等提交。
     adoptPreview.value = await previewManagedOnboarding({
       merchantId: adoptForm.merchantId,
       providerHuifuId: adoptForm.providerHuifuId.trim(),
+      providerApplyNo: adoptForm.providerApplyNo.trim(),
     })
     adoptConfirmed.value = false
   } catch (error: any) {
@@ -498,7 +501,8 @@
           <el-input v-model="adoptForm.providerHuifuId" clearable placeholder="斗拱侧该商户的 huifu_id" @input="invalidateAdoptPreview" />
         </el-form-item>
         <el-form-item label="入驻申请单号" required>
-          <el-input v-model="adoptForm.providerApplyNo" clearable placeholder="汇付控台入驻申请列表里的申请单号" />
+          <!-- 改号同样作废上一次核验:否则会出现「核验的是这个号、提交的却是另一个号」。 -->
+          <el-input v-model="adoptForm.providerApplyNo" clearable placeholder="汇付控台入驻申请列表里的申请单号" @input="invalidateAdoptPreview" />
         </el-form-item>
         <el-form-item label=" ">
           <el-button type="primary" plain :loading="adoptPreviewing" @click="runAdoptPreview">核验通道商户</el-button>
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java b/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java
index 43c14d6..05a7750 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java
@@ -44,7 +44,7 @@
    * 运营核对并显式确认后走 {@code managed} 提交。两者都挂在既有的 {@code onboardings} 前缀下,
    * 权限沿用 {@code admin.merchant-settlement.edit}(GET 为 view、其余为 edit),不新增权限码。
    */
-  @PostMapping("/merchant-settlement/onboardings/managed/preview") public ApiResponse<Map<String,Object>> previewManagedOnboarding(@RequestBody MerchantManagedOnboardingRequest request) { return ApiResponse.success(managedOnboardingService.preview(request.merchantId(), request.providerHuifuId())); }
+  @PostMapping("/merchant-settlement/onboardings/managed/preview") public ApiResponse<Map<String,Object>> previewManagedOnboarding(@RequestBody MerchantManagedOnboardingRequest request) { return ApiResponse.success(managedOnboardingService.preview(request.merchantId(), request.providerHuifuId(), request.providerApplyNo())); }
   @PostMapping("/merchant-settlement/onboardings/managed") public ApiResponse<Map<String,Object>> adoptManagedOnboarding(@RequestBody MerchantManagedOnboardingRequest request) { return ApiResponse.success(managedOnboardingService.adopt(request.merchantId(), request.providerHuifuId(), request.providerApplyNo(), Boolean.TRUE.equals(request.confirmed()))); }
   /** 解绑:只解除我方绑定,只清理这条纳管产生的进件与结算卡记录,不回退通道侧的任何改动。 */
   @PostMapping("/merchant-settlement/onboardings/{id}/unbind") public ApiResponse<Map<String,Object>> unbindManagedOnboarding(@PathVariable Long id) { return ApiResponse.success(managedOnboardingService.unbind(id)); }
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantManagedOnboardingService.java b/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantManagedOnboardingService.java
index 1c67d01..d37f3aa 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantManagedOnboardingService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantManagedOnboardingService.java
@@ -77,13 +77,18 @@
      *
      * <p>查不到就明确拒绝——那说明该商户不在我们**渠道商**的管辖范围内,纳管进行不下去。注意把商户
      * 改成挂在平台自己名下既解决不了权限失败、还会平白多出材料要求,不是「迁一下归属就能解决」的事。
+     *
+     * <p>已经填了入驻申请单号的话,顺带把 `(商户号, 申请单号)` 配一次对(见 {@link #applyNoMismatch}):
+     * 错号在提交时才被拒,运营会白填一屏还看不出哪一项有问题。
      */
-    public Map<String, Object> preview(Long merchantId, String huifuId) {
+    public Map<String, Object> preview(Long merchantId, String huifuId, String applyNo) {
         MerchantRef merchant = requireMerchant(merchantId);
         String providerHuifuId = requireHuifuId(huifuId);
         Map<String, Object> channel = channelMerchant(providerHuifuId);
         Long storeId = primaryStore(merchantId);
         Map<String, Object> card = pickSettlementCard(channel);
+        String providerApplyNo = clean(applyNo);
+        String mismatched = providerApplyNo.isBlank() ? "" : applyNoMismatch(providerHuifuId, providerApplyNo);
 
         Map<String, Object> view = new LinkedHashMap<>();
         view.put("merchantId", String.valueOf(merchant.id()));
@@ -108,8 +113,9 @@
         Map<String, Object> active = activeApplication(merchantId, storeId);
         boolean alreadyManaged = active != null && providerHuifuId.equals(clean(active.get("providerHuifuId")));
         view.put("alreadyManaged", alreadyManaged);
-        // 硬拦截项在这里先告诉运营,免得填完一屏才被拒。
+        // 硬拦截项在这里先告诉运营,免得填完一屏才被拒。申请单号是刚填的那一项,排在第一个。
         List<String> blockers = new ArrayList<>();
+        if (!mismatched.isBlank()) blockers.add(mismatched);
         if (card == null) blockers.add("通道未返回可用的结算银行卡,无法纳管(分阶段进度与收款闸门都要求先绑定结算卡)");
         if (active != null && !alreadyManaged) blockers.add(conflictMessage(active));
         view.put("blockers", blockers);
@@ -147,6 +153,9 @@
         if (!conflict.isBlank()) throw new BusinessException(ErrorCode.CONFLICT, conflict);
 
         Map<String, Object> channel = channelMerchant(providerHuifuId);
+        // 商户号核得住不代表申请单号核得住:这两个字段是分开填的,必须配一次对才能建记录。
+        String mismatched = applyNoMismatch(providerHuifuId, providerApplyNo);
+        if (!mismatched.isBlank()) throw new BusinessException(ErrorCode.CONFLICT, mismatched);
         Map<String, Object> card = pickSettlementCard(channel);
         if (card == null) throw new BusinessException(ErrorCode.CONFLICT, "通道未返回可用的结算银行卡,无法纳管");
         String cardNumber = clean(card.get("card_no"));
@@ -163,6 +172,30 @@
     }
 
     /**
+     * 把 `(商户号, 申请单号)` 配一次对:**两个字段是一起送给通道的,通道会交叉校验**,所以填错的号
+     * ——包括"真实存在、但属于别的商户号"的号——都会回 `00000001 申请单不存在`(2026-10-12 实测过这两种)。
+     *
+     * <p>为什么必须在建记录之前拦:错号**不会误放**(基础审核确认不了、进件永远 pending、闸门一直拒),
+     * 但会留下一条收不了款的记录,而刷新给出的却是「汇付查询未成功,请稍后刷新或联系平台运营」——
+     * 那句话看起来像通道抖动,实际是号填错了;更麻烦的是重复纳管是幂等的、不会用新号覆盖旧号,只能解绑重来。
+     * 拦在这里之后,运营当场就知道是哪一栏错了。
+     *
+     * <p>返回空串表示配得上;否则返回给运营看的原话。只查询,不下发。
+     */
+    private String applyNoMismatch(String huifuId, String applyNo) {
+        Map<String, Object> response;
+        try {
+            response = providerGateway.queryApplication(huifuId, applyNo);
+        } catch (RuntimeException exception) {
+            return "申请单号核验失败:通道查询暂时不通,请稍后重试";
+        }
+        if (response != null && "00000000".equals(clean(response.get("resp_code")))) return "";
+        String description = response == null ? "" : clean(response.get("resp_desc"));
+        return "申请单号与该商户号不匹配(" + (description.isBlank() ? "通道未返回原因" : description)
+                + "):两个字段必须属于同一笔进件,请到汇付控台核对后重填";
+    }
+
+    /**
      * 重复纳管命中的是上一次留下的那条记录:不新建,但也不能什么都不做。
      *
      * <p>已通过的记录直接回它(再刷一次只可能把通过翻回待办,没有好处);还在办理中的**再跑一次进度
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/admin/MerchantManagedOnboardingTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/admin/MerchantManagedOnboardingTests.java
index a459b8b..3ff852b 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/admin/MerchantManagedOnboardingTests.java
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/admin/MerchantManagedOnboardingTests.java
@@ -83,7 +83,8 @@
 
         service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true);
 
-        verify(gateway).queryApplication(eq(huifuId), eq(APPLY_NO));
+        // 两次:一次是建记录前的配对核验,一次是进度刷新本身——两处都只拿这个通道申请单号定位。
+        verify(gateway, times(2)).queryApplication(eq(huifuId), eq(APPLY_NO));
         assertThat(adoptedApplicationId(fixture.merchantId(), huifuId)).isNotNull();
     }
 
@@ -135,7 +136,7 @@
         Fixture fixture = merchantFixture("A甲乙丙丁戊己");
         stubConvergedChannel(huifuId, "622200001234", "external-card-token", "上海汇付支付服务有限公司", "汇付支付");
 
-        Map<String, Object> preview = service.preview(fixture.merchantId(), huifuId);
+        Map<String, Object> preview = service.preview(fixture.merchantId(), huifuId, APPLY_NO);
 
         assertThat(String.valueOf(preview.get("nameWarning"))).contains("没有任何相同字符");
         assertThat(preview).containsEntry("channelMerchantName", "上海汇付支付服务有限公司");
@@ -161,7 +162,7 @@
         service.adopt(first.merchantId(), huifuId, APPLY_NO, true);
         // 门店名与商家名一样时不重复印一遍——多数门店就是这样,列表里不该出现「某某商户(某某商户)」。
         jdbc.update("update merchant_store set store_name = ? where id = ?", first.key(), first.storeId());
-        Map<String, Object> preview = service.preview(second.merchantId(), huifuId);
+        Map<String, Object> preview = service.preview(second.merchantId(), huifuId, APPLY_NO);
 
         assertThat(preview.get("boundMerchants"))
                 .asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.list(String.class))
@@ -187,7 +188,7 @@
         assertThat(jdbc.queryForObject("select count(*) from merchant_bank_card where merchant_id = ?",
                 Integer.class, fixture.merchantId())).isEqualTo(1);
         // 已经纳管过的商户号在核验里只说明会复用原记录,不能报成「门店已有进件」这种硬拦截。
-        Map<String, Object> preview = service.preview(fixture.merchantId(), huifuId);
+        Map<String, Object> preview = service.preview(fixture.merchantId(), huifuId, APPLY_NO);
         assertThat(preview).containsEntry("alreadyManaged", true);
         assertThat((List<?>) preview.get("blockers")).isEmpty();
     }
@@ -225,7 +226,7 @@
 
         service.adopt(fixture.merchantId(), firstHuifuId, APPLY_NO, true);
 
-        assertThat(service.preview(fixture.merchantId(), secondHuifuId).get("blockers"))
+        assertThat(service.preview(fixture.merchantId(), secondHuifuId, APPLY_NO).get("blockers"))
                 .asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.list(String.class))
                 .anySatisfy(blocker -> assertThat(blocker).contains("同一门店出现两个收款主体"));
         assertThatThrownBy(() -> service.adopt(fixture.merchantId(), secondHuifuId, APPLY_NO, true))
@@ -381,7 +382,7 @@
         jdbc.update("update merchant set can_login_merchant_app = 0 where id = ?", fixture.merchantId());
         stubConvergedChannel(huifuId, "622200001234", "external-card-token");
 
-        Map<String, Object> preview = service.preview(fixture.merchantId(), huifuId);
+        Map<String, Object> preview = service.preview(fixture.merchantId(), huifuId, APPLY_NO);
 
         assertThat(String.valueOf(preview.get("loginNotice"))).contains("未开启商户端登录");
         // 提示不阻挡纳管:纳管只管收款绑定,账号由「商家查询」负责。
@@ -406,7 +407,7 @@
                 """, fixture.merchantId(), "t01ok" + Math.abs(System.nanoTime()));
         stubConvergedChannel(huifuId, "622200001234", "external-card-token");
 
-        assertThat(service.preview(fixture.merchantId(), huifuId).get("loginNotice")).isEqualTo("");
+        assertThat(service.preview(fixture.merchantId(), huifuId, APPLY_NO).get("loginNotice")).isEqualTo("");
     }
 
     /**
@@ -450,8 +451,10 @@
         Map<String, Object> detail = channelDetail(huifuId, "622200001234", "token", "Y", true);
         detail.put("qry_cash_card_info_list", List.of(Map.of("card_no", "6222****1234", "token_no", "masked-token")));
         when(gateway.queryCapabilities(huifuId)).thenReturn(detail);
+        // 申请单号这一关先放行,本用例只盯"没有可用结算卡":否则先撞上的是配对校验。
+        when(gateway.queryApplication(anyString(), anyString())).thenReturn(Map.of("resp_code", "00000000"));
 
-        assertThat(service.preview(fixture.merchantId(), huifuId).get("blockers"))
+        assertThat(service.preview(fixture.merchantId(), huifuId, APPLY_NO).get("blockers"))
                 .asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.list(String.class))
                 .anySatisfy(blocker -> assertThat(blocker).contains("结算银行卡"));
         assertThatThrownBy(() -> service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true))
@@ -459,6 +462,36 @@
                 .hasMessageContaining("结算银行卡");
     }
 
+    /**
+     * 申请单号填错时必须在**建记录之前**被拦下,而且要说清是哪一栏错了。
+     *
+     * <p>错号不会误放(基础审核确认不了、收不了款),真正的问题是它留下一具尸体:记录建出来了、
+     * 刷新报的却是「汇付查询未成功,请稍后刷新」——看起来像通道抖动;而重复纳管是幂等的、不会用
+     * 新号覆盖旧号,运营只能解绑重来。通道对这两个字段是**一起**校验的(填错的号,包括"真实存在但
+     * 属于别的商户号"的号,都回 `00000001 申请单不存在`),所以这里配一次对就能拦住。
+     */
+    @Test
+    void adoptingWithAnApplyNumberThatDoesNotBelongToTheMerchantIsRejected() {
+        String huifuId = uniqueHuifuId();
+        Fixture fixture = merchantFixture("号不匹配");
+        when(gateway.queryCapabilities(huifuId)).thenReturn(channelDetail(huifuId, "622200001234", "card-token", "Y", true));
+        when(gateway.queryApplication(anyString(), anyString())).thenReturn(Map.of("resp_code", "00000001", "resp_desc", "申请单不存在"));
+
+        assertThatThrownBy(() -> service.adopt(fixture.merchantId(), huifuId, "999999999999999999", true))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("申请单号与该商户号不匹配")
+                .hasMessageContaining("申请单不存在");
+        // 一具尸体都不该留下:进件记录与结算卡都不建。
+        assertThat(jdbc.queryForObject("select count(*) from merchant_onboarding_application where merchant_id = ?",
+                Integer.class, fixture.merchantId())).isZero();
+        assertThat(jdbc.queryForObject("select count(*) from merchant_bank_card where merchant_id = ?",
+                Integer.class, fixture.merchantId())).isZero();
+        // 核验那一步也要提前把这条说出来,运营不用等提交。
+        assertThat(service.preview(fixture.merchantId(), huifuId, "999999999999999999").get("blockers"))
+                .asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.list(String.class))
+                .anySatisfy(blocker -> assertThat(blocker).contains("申请单不存在"));
+    }
+
     private Long adoptedApplicationId(Long merchantId, String huifuId) {
         return jdbc.queryForObject("select id from merchant_onboarding_application where merchant_id = ? and provider_huifu_id = ?",
                 Long.class, merchantId, huifuId);

--
Gitblit v1.7.1