From 65085442d5e6dfe2eaa0c7794a2fbc09d801a0dd Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期一, 12 十月 2026 14:36:19 +0800
Subject: [PATCH] fix(merchant): 纳管的商家登录提示不再误报,并修掉读库键名写错

---
 talent-card-server/src/test/java/com/talentcard/server/business/admin/MerchantManagedOnboardingTests.java   |   54 +++++++++++++++++++++++++++
 talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantManagedOnboardingService.java |   58 ++++++++++++++++++-----------
 2 files changed, 90 insertions(+), 22 deletions(-)

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 68ee993..1c67d01 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
@@ -79,15 +79,15 @@
      * 改成挂在平台自己名下既解决不了权限失败、还会平白多出材料要求,不是「迁一下归属就能解决」的事。
      */
     public Map<String, Object> preview(Long merchantId, String huifuId) {
-        Map<String, Object> merchant = requireMerchant(merchantId);
+        MerchantRef merchant = requireMerchant(merchantId);
         String providerHuifuId = requireHuifuId(huifuId);
         Map<String, Object> channel = channelMerchant(providerHuifuId);
         Long storeId = primaryStore(merchantId);
         Map<String, Object> card = pickSettlementCard(channel);
 
         Map<String, Object> view = new LinkedHashMap<>();
-        view.put("merchantId", String.valueOf(merchant.get("id")));
-        view.put("merchantName", clean(merchant.get("merchantName")));
+        view.put("merchantId", String.valueOf(merchant.id()));
+        view.put("merchantName", merchant.name());
         view.put("storeId", String.valueOf(storeId));
         view.put("storeName", storeName(storeId));
         view.put("providerHuifuId", providerHuifuId);
@@ -100,10 +100,10 @@
         view.put("settlementBankName", card == null ? "" : clean(card.get("bank_name")));
         // 只提示不拦截:连锁各门店共用同一个收款主体是本次有意支持的做法,提示里列出已绑的商家即可。
         view.put("boundMerchants", boundMerchantNames(providerHuifuId, merchantId));
-        view.put("nameWarning", nameWarning(clean(merchant.get("merchantName")),
+        view.put("nameWarning", nameWarning(merchant.name(),
                 clean(channel.get("reg_name")), clean(channel.get("short_name"))));
         // 纳管只管收款绑定,不创建商户端登录账号;但商户登不进来要让运营知道。
-        view.put("loginNotice", loginNotice(merchantId, merchant));
+        view.put("loginNotice", loginNotice(merchant));
         // 已经纳管过这个商户号时不算冲突:重复纳管是幂等的,提交会直接返回原进件,别让运营以为要先解绑。
         Map<String, Object> active = activeApplication(merchantId, storeId);
         boolean alreadyManaged = active != null && providerHuifuId.equals(clean(active.get("providerHuifuId")));
@@ -131,7 +131,7 @@
      * 而通道查询本来也不该夹在持有行锁的事务里。
      */
     public Map<String, Object> adopt(Long merchantId, String huifuId, String applyNo, boolean confirmed) {
-        Map<String, Object> merchant = requireMerchant(merchantId);
+        MerchantRef merchant = requireMerchant(merchantId);
         String providerHuifuId = requireHuifuId(huifuId);
         String providerApplyNo = clean(applyNo);
         if (providerApplyNo.isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "请填写汇付侧的入驻申请单号:没有它,通道的申请单状态查询(该接口 apply_no 必填)无法推进,进度会永远停在未确认");
@@ -286,9 +286,9 @@
     /**
      * 建结算卡记录,沿用现有的字段级加密与卡号哈希,不新造一套(卡哈希就是进度核实与绑卡去重的依据)。
      */
-    private Long insertSettlementCard(Map<String, Object> merchant, Long storeId, String huifuId,
+    private Long insertSettlementCard(MerchantRef merchant, Long storeId, String huifuId,
                                      Map<String, Object> card, String cardNumber) {
-        Long merchantId = ((Number) merchant.get("id")).longValue();
+        Long merchantId = merchant.id();
         String cardKey = managedBankCardIdempotencyKey(huifuId);
         // 卡哈希在 (merchant_id, card_hash) 上唯一,幂等键在 (merchant_id, idempotency_key) 上唯一;
         // 同一商家重复纳管会先被进件那一侧的幂等拦掉,走不到这里。
@@ -309,20 +309,20 @@
      * 建进件记录:非空字段(主体类型、主体名、联系人、联系电话、申请单号、幂等键)全部填满,
      * 通道能反填的用通道的,拿不到的从我们的商家记录取,不留空。
      */
-    private Long insertApplication(Map<String, Object> merchant, Long storeId, String huifuId, String applyNo,
+    private Long insertApplication(MerchantRef merchant, Long storeId, String huifuId, String applyNo,
                                    String applicationNo, String idempotencyKey, Map<String, Object> channel, Long bankCardId) {
-        Long merchantId = ((Number) merchant.get("id")).longValue();
-        String subjectType = "1".equals(clean(channel.get("cust_type"))) ? "enterprise" : "individual";
+        Long merchantId = merchant.id();
+        String subjectType = subjectTypeOf(channel);
         String channelName = clean(channel.get("reg_name"));
-        String merchantName = clean(merchant.get("merchantName"));
+        String merchantName = merchant.name();
         // 主体名取通道的**商户注册名称**(工商全称):进件列表的「进件主体」列就是它,运营要能一眼
         // 比对我方商家名与通道主体名,才看得出有没有绑错主体。通道没给才回落到法人姓名/我们的商家名。
         // 同一份名称也写进 merchant_name / merchant_short_name(详情页的「商户名称 / 商户简称」)——
         // 对纳管记录来说,「通道主体名」与「通道商户名称」本来就是同一个工商全称。
         String subjectName = firstNonBlank(channelName, clean(channel.get("legal_name")), merchantName);
-        String contactName = firstNonBlank(clean(channel.get("contact_name")), clean(merchant.get("contact_name")),
+        String contactName = firstNonBlank(clean(channel.get("contact_name")), merchant.contactName(),
                 clean(channel.get("legal_name")), merchantName);
-        String contactMobile = firstNonBlank(clean(channel.get("contact_mobile_no")), clean(merchant.get("contact_mobile")),
+        String contactMobile = firstNonBlank(clean(channel.get("contact_mobile_no")), merchant.contactMobile(),
                 clean(channel.get("legal_mobile_no")));
         String legalMobile = firstNonBlank(clean(channel.get("legal_mobile_no")), contactMobile);
         String idNumber = clean(channel.get("legal_cert_no"));
@@ -330,7 +330,7 @@
                 ? firstNonBlank(COMPANY_TYPE_BY_ENT_TYPE.getOrDefault(clean(channel.get("ent_type")), ""), "私营企业")
                 // 个人主体的 company_type 是经营行业(决定 MCC,而 MCC 在自助进件那边才下发),
                 // 纳管不下发,所以这里取我们商家档案上的经营类型,与「行业以系统档案为准」口径一致。
-                : firstNonBlank(clean(merchant.get("merchant_type")), "其它");
+                : firstNonBlank(merchant.type(), "其它");
         jdbc.update("""
                 insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type,
                     subject_name, merchant_name, merchant_short_name, company_type, credit_code,
@@ -457,26 +457,40 @@
     /**
      * 纳管不建商户端登录账号(账号由「商家查询」的商家编辑负责),但商户登不进来要让运营知道。
      */
-    private String loginNotice(Long merchantId, Map<String, Object> merchant) {
-        if (!"1".equals(clean(merchant.get("can_login_merchant_app")))) {
+    private String loginNotice(MerchantRef merchant) {
+        if (!merchant.loginEnabled()) {
             return "该商家未开启商户端登录,纳管不会为它创建登录账号;商户要自己看收款状态,请先到「商家查询」开启商户端登录并设置账号。";
         }
         Integer accounts = jdbc.queryForObject(
                 "select count(*) from merchant_operator where merchant_id = ? and status = 1 and deleted = 0",
-                Integer.class, merchantId);
+                Integer.class, merchant.id());
         return accounts == null || accounts == 0
                 ? "该商家还没有商户端登录账号,纳管不会为它创建;请到「商家查询」为该商家编辑并设置登录账号,否则商户登不进来。"
                 : "";
     }
 
-    private Map<String, Object> requireMerchant(Long merchantId) {
+    private MerchantRef requireMerchant(Long merchantId) {
         if (merchantId == null) throw new BusinessException(ErrorCode.BAD_REQUEST, "请选择我方商家");
         List<Map<String, Object>> merchants = rows(
                 "select id, merchant_name, merchant_type, contact_name, contact_mobile, can_login_merchant_app from merchant where id = ? and deleted = 0 limit 1",
                 merchantId);
         if (merchants.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "商家不存在");
-        return merchants.get(0);
+        Map<String, Object> row = merchants.get(0);
+        return new MerchantRef(((Number) row.get("id")).longValue(), clean(row.get("merchantName")),
+                clean(row.get("merchantType")), clean(row.get("contactName")), clean(row.get("contactMobile")),
+                "1".equals(clean(row.get("canLoginMerchantApp"))));
     }
+
+    /**
+     * 我方商家在纳管里用到的字段。
+     *
+     * <p>存在的理由不只是省几个参数:库行经 {@link DatabaseRows#normalize} 之后键是 **camelCase**,而通道
+     * 回执的键是 snake_case,两种 map 用同一个 {@code get()} 取,很容易把列名写成 snake_case 去读——那会
+     * 静默取到 null(`clean(null)` 是空串),表现为「登录明明开着却说没开」「联系人莫名回落成别的字段」。
+     * 列名只在这一处读一次,其余地方一律走这个 record,就没有再写错键的机会。
+     */
+    private record MerchantRef(long id, String name, String type, String contactName, String contactMobile,
+                               boolean loginEnabled) { }
 
     private String requireHuifuId(String huifuId) {
         String value = clean(huifuId);
@@ -527,8 +541,8 @@
         return "1".equals(clean(channel.get("cust_type"))) ? "enterprise" : "individual";
     }
 
-    private String cardBankName(Map<String, Object> card, Map<String, Object> merchant) {
-        return firstNonBlank(clean(card.get("bank_name")), clean(merchant.get("merchantName")));
+    private String cardBankName(Map<String, Object> card, MerchantRef merchant) {
+        return firstNonBlank(clean(card.get("bank_name")), merchant.name());
     }
 
     /** 对私结算的支行名不一定有值,但该列非空:拿不到就回落总行名,不硬造。 */
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 371c846..a459b8b 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
@@ -388,6 +388,60 @@
         assertThat(service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true)).containsEntry("status", "approved");
     }
 
+    /**
+     * 登录开着、账号也在的商家**一条提示都不该有**。
+     *
+     * <p>这条钉的是一个真出现过的静默 bug:库行经 {@code DatabaseRows.normalize} 后键是 camelCase,
+     * 通道回执是 snake_case,两种 map 用同一个 {@code get()} 取,于是把列名写成 snake_case 去读库行会
+     * 取到 null(不报错),`!"1".equals("")` 恒真——登录明明开着,界面上却永远挂着「未开启商户端登录」。
+     * 只有「正向不提示」的断言能发现它,只测「没开时要提示」是测不出来的。
+     */
+    @Test
+    void noLoginNoticeAtAllWhenTheMerchantCanAlreadySignIn() {
+        String huifuId = uniqueHuifuId();
+        Fixture fixture = merchantFixture("登录开着");
+        jdbc.update("""
+                insert into merchant_operator(merchant_id, account, password_hash, display_name, role_code, status, deleted)
+                values (?, ?, 'x', '店主', 'owner', 1, 0)
+                """, fixture.merchantId(), "t01ok" + Math.abs(System.nanoTime()));
+        stubConvergedChannel(huifuId, "622200001234", "external-card-token");
+
+        assertThat(service.preview(fixture.merchantId(), huifuId).get("loginNotice")).isEqualTo("");
+    }
+
+    /**
+     * 通道没给联系人/手机号时,这些非空字段要从**我们自己的商家记录**取,不能留空、也不能顶成别的字段。
+     *
+     * <p>与上一条同源:它覆盖的正是「拿 snake_case 的键去读库行」会静默取到 null 的那几处。
+     */
+    @Test
+    void missingChannelContactFieldsFallBackToOurMerchantRecord() {
+        String huifuId = uniqueHuifuId();
+        Fixture fixture = merchantFixture("通道缺联系人");
+        Map<String, Object> detail = channelDetail(huifuId, "622200001234", "external-card-token", "Y", true);
+        // 个人主体:company_type 取我们商家档案上的经营类型;通道侧的联系人三项全部不给。
+        detail.put("cust_type", "2");
+        detail.remove("contact_name");
+        detail.remove("contact_mobile_no");
+        detail.remove("legal_mobile_no");
+        when(gateway.queryCapabilities(huifuId)).thenReturn(detail);
+        when(gateway.queryApplication(anyString(), anyString())).thenReturn(Map.of(
+                "resp_code", "00000000", "apply_status", "Y", "reg_status", "S", "bind_card_status", "S"));
+
+        service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true);
+
+        // merchantFixture 的商家记录:contact_name=联系人、contact_mobile=13700000001、merchant_type=餐饮服务
+        assertThat(column(fixture, "contact_name")).isEqualTo("联系人");
+        assertThat(column(fixture, "contact_mobile")).isEqualTo("13700000001");
+        assertThat(column(fixture, "company_type")).isEqualTo("餐饮服务");
+        assertThat(column(fixture, "subject_type")).isEqualTo("individual");
+    }
+
+    private String column(Fixture fixture, String name) {
+        return jdbc.queryForObject("select " + name + " from merchant_onboarding_application where merchant_id = ?",
+                String.class, fixture.merchantId());
+    }
+
     /** 通道没给出可用结算卡时不纳管:没有结算卡,进度不会推进、闸门也不会放行。 */
     @Test
     void adoptingIsRejectedWhenTheChannelReturnsNoUsableCard() {

--
Gitblit v1.7.1