yeg
2 天以前 3773bc4f2d966fb248f96c7e5cedce58c91df689
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
package com.talentcard.server.business.admin;
 
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.merchant.MerchantOnboardingProgressService;
import com.talentcard.server.business.merchant.MerchantPaymentReadinessService;
import com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import com.talentcard.server.common.SensitiveDataCrypto;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
 
/**
 * 外部进件商户的纳管与解绑:把「已在汇付侧完成进件」的商户接到我方某个商家上,使它能直接用我们这套
 * 进件记录、分阶段进度与收款闸门收款;绑错了能解绑重来。
 *
 * <p>为什么不新造链路:纳管产出的就是一条进件记录。它复用既有的进件记录、六阶段进度、收款能力快照
 * 与收款就绪判定,只在「写入口」和「来源标记」两处新增——{@code provider_huifu_id} 原先三个写入口
 * 全部来自汇付回执,纳管是第四个(见 V157 的 source 列)。
 *
 * <p><b>全程只查询、不下发。</b> 外部进件商户的口径已由业务要求与自助进件保持一致,纳管只需核实;
 * 而补开微信、绑人才端 AppID、改支付场景、开启延迟入账这些下发操作都不幂等(改支付场景会重建微信
 * 子商户号),且连锁多店共用同一个汇付商户号时各自下发会造成重复下发。所以:
 * <ul>
 *   <li>进度刷新走 {@link MerchantOnboardingProgressService#refresh}(纯查询);</li>
 *   <li>{@code source=managed} 的记录在刷新链路里一律跳过下发步骤(延迟入账、微信配置、业务开通)。</li>
 * </ul>
 *
 * <p>防绑错靠**人工核对**,不靠程序判定:我们这边 {@code merchant} 只有一个商家名(往往是服务点名或
 * 品牌简称),通道那边是工商全称加简称,形态不对应,字符串比较必然误判——误拒会把运营卡死且无路可绕,
 * 误放等于没设防。所以程序只判确定性的事(用我们的签名身份查得到该商户),名称改成界面展示 + 运营
 * 显式勾选确认,并在两个名称与商家名一个字符都不重叠时给出醒目警示(不硬拒,可能是简称、别名或服务点名)。
 */
@Service
public class MerchantManagedOnboardingService {
    /**
     * 通道的企业种类取值 → 我们进件记录里的公司类型。
     *
     * <p>与 {@code MerchantSettlementService.entType} 是同一张表的两面:那里是「我们的取值 → 通道代码」,
     * 这里是通道代码 → 我们的取值。取值必须一致,否则对私/对公结算凭证的判定会跟着漂。
     */
    private static final Map<String, String> COMPANY_TYPE_BY_ENT_TYPE = Map.ofEntries(
            Map.entry("1", "政府机构"), Map.entry("2", "国营企业"), Map.entry("3", "私营企业"),
            Map.entry("4", "外资企业"), Map.entry("5", "个体工商户"), Map.entry("6", "其它组织"),
            Map.entry("7", "事业单位"), Map.entry("9", "业主委员会"));
 
    private final JdbcTemplate jdbc;
    private final HuifuOnboardingProgressGateway providerGateway;
    private final SensitiveDataCrypto sensitiveDataCrypto;
    private final MerchantOnboardingProgressService progressService;
    private final MerchantPaymentReadinessService readinessService;
    private final TransactionTemplate transactions;
 
    public MerchantManagedOnboardingService(JdbcTemplate jdbc, HuifuOnboardingProgressGateway providerGateway,
                                           SensitiveDataCrypto sensitiveDataCrypto,
                                           MerchantOnboardingProgressService progressService,
                                           MerchantPaymentReadinessService readinessService,
                                           TransactionTemplate transactions) {
        this.jdbc = jdbc;
        this.providerGateway = providerGateway;
        this.sensitiveDataCrypto = sensitiveDataCrypto;
        this.progressService = progressService;
        this.readinessService = readinessService;
        this.transactions = transactions;
    }
 
    /**
     * 纳管前置核验:用我们自己的签名身份只读查一次该商户,把要人工核对的材料取回来。
     *
     * <p>查不到就明确拒绝——那说明该商户不在我们**渠道商**的管辖范围内,纳管进行不下去。注意把商户
     * 改成挂在平台自己名下既解决不了权限失败、还会平白多出材料要求,不是「迁一下归属就能解决」的事。
     *
     * <p>已经填了入驻申请单号的话,顺带把 `(商户号, 申请单号)` 配一次对(见 {@link #applyNoMismatch}):
     * 错号在提交时才被拒,运营会白填一屏还看不出哪一项有问题。
     */
    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()));
        view.put("merchantName", merchant.name());
        view.put("storeId", String.valueOf(storeId));
        view.put("storeName", storeName(storeId));
        view.put("providerHuifuId", providerHuifuId);
        // 通道返回的两个名字与主体类型:运营拿它们与所选商家核对,勾选确认后才能提交。
        view.put("channelMerchantName", clean(channel.get("reg_name")));
        view.put("channelShortName", clean(channel.get("short_name")));
        view.put("channelSubjectType", subjectTypeOf(channel));
        view.put("channelCompanyType", COMPANY_TYPE_BY_ENT_TYPE.getOrDefault(clean(channel.get("ent_type")), ""));
        view.put("settlementCardMasked", card == null ? "" : maskedCardNumber(clean(card.get("card_no"))));
        view.put("settlementBankName", card == null ? "" : clean(card.get("bank_name")));
        // 只提示不拦截:连锁各门店共用同一个收款主体是本次有意支持的做法,提示里列出已绑的商家即可。
        view.put("boundMerchants", boundMerchantNames(providerHuifuId, merchantId));
        view.put("nameWarning", nameWarning(merchant.name(),
                clean(channel.get("reg_name")), clean(channel.get("short_name"))));
        // 纳管只管收款绑定,不创建商户端登录账号;但商户登不进来要让运营知道。
        view.put("loginNotice", loginNotice(merchant));
        // 已经纳管过这个商户号时不算冲突:重复纳管是幂等的,提交会直接返回原进件,别让运营以为要先解绑。
        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);
        return view;
    }
 
    /**
     * 纳管:为该商家建立结算卡记录与进件记录,并触发一次进度刷新,使分阶段进度与收款能力快照在既有
     * 模型内收敛。
     *
     * <p>幂等由「由汇付商户号派生的键」保证:{@code application_no} 与 {@code idempotency_key} 都是
     * 唯一键且非空(见 V80),两个键都只从「商户号 + 门店」派生,重复点击不会产生第二条进件记录。
     *
     * <p>{@code confirmed} 是运营在对话框里勾的那个确认框:通道的商户注册名称与简称是运营肉眼核对过的,
     * 程序不为它做字符串判定(见类注释)。
     *
     * <p>两条写入放在同一个事务里(半途失败不留孤儿结算卡),但**进度刷新与能力快照在事务外**:
     * 刷新是 {@code NOT_SUPPORTED} 的独立连接,在未提交的事务里它查不到这条刚建的进件;
     * 而通道查询本来也不该夹在持有行锁的事务里。
     */
    public Map<String, Object> adopt(Long merchantId, String huifuId, String applyNo, boolean confirmed) {
        MerchantRef merchant = requireMerchant(merchantId);
        String providerHuifuId = requireHuifuId(huifuId);
        String providerApplyNo = clean(applyNo);
        if (providerApplyNo.isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "请填写汇付侧的入驻申请单号:没有它,通道的申请单状态查询(该接口 apply_no 必填)无法推进,进度会永远停在未确认");
        if (!confirmed) throw new BusinessException(ErrorCode.BAD_REQUEST, "请先核对通道返回的商户注册名称与简称,并勾选确认后再提交");
        Long storeId = primaryStore(merchantId);
        String idempotencyKey = managedIdempotencyKey(providerHuifuId);
        // 先看幂等:这一条查的就是我们自己上一次纳管留下的记录,别把它当成「门店已有进件」的冲突。
        List<Map<String, Object>> repeated = rows(
                "select * from merchant_onboarding_application where merchant_id = ? and idempotency_key = ? limit 1",
                merchantId, idempotencyKey);
        if (!repeated.isEmpty()) return reapplyExisting(repeated.get(0));
        String conflict = activeConflict(merchantId, storeId);
        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"));
        String applicationNo = managedApplicationNo(providerHuifuId, storeId);
        Long applicationId = transactions.execute(status -> {
            Long bankCardId = insertSettlementCard(merchant, storeId, providerHuifuId, card, cardNumber);
            return insertApplication(merchant, storeId, providerHuifuId, providerApplyNo,
                    applicationNo, idempotencyKey, channel, bankCardId);
        });
        // 纳管之后收敛:只查询,不下发。进度刷新负责六个阶段,能力快照负责收款闸门读的那几个字段。
        progressService.refresh(applicationId, false);
        refreshCapabilityWhenApproved(applicationId);
        return progressService.view(applicationId);
    }
 
    /**
     * 把 `(商户号, 申请单号)` 配一次对:**两个字段是一起送给通道的,通道会交叉校验**,所以填错的号
     * ——包括"真实存在、但属于别的商户号"的号——都会回 `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>已通过的记录直接回它(再刷一次只可能把通过翻回待办,没有好处);还在办理中的**再跑一次进度
     * 刷新**,让重复点击真的是一次重试。被通道驳回的记录刷新救不回来({@code supports()} 对 rejected
     * 返回 false,刷新原样返回),必须明确告诉运营去解绑后重来,而不是报一句「纳管成功」却什么都没发生。
     */
    private Map<String, Object> reapplyExisting(Map<String, Object> existing) {
        Long applicationId = ((Number) existing.get("id")).longValue();
        if ("rejected".equals(clean(existing.get("status")))) {
            throw new BusinessException(ErrorCode.CONFLICT, "该商家纳管的这条进件已被通道驳回("
                    + clean(existing.get("applicationNo")) + "),不能重复纳管;请先在进件详情里解绑,再重新纳管");
        }
        if (!"approved".equals(clean(existing.get("status")))) progressService.refresh(applicationId, false);
        return progressService.view(applicationId);
    }
 
    /**
     * 解绑:解除我方绑定,清理这条纳管产生的进件记录与结算卡记录(结算卡同时从「银行卡申请」页签消失)。
     *
     * <p>**不回退通道侧的任何改动**:微信口径、人才端 AppID 绑定、延迟入账、实名都不是这次纳管改的,
     * 解绑也不会把它们撤销。这一点界面上必须写明,否则运营会误以为商户已恢复原样。
     *
     * <p>只允许解绑纳管产生的记录:自助进件那条记录里是商户自己提交的资料,删掉它是另一个性质的操作,
     * 不在这张票的范围里。
     *
     * <p>「回收商户端收款相关权限」就是这条删除本身:商户端的收款权限没有单独的授权行,它是从进件记录
     * 派生出来的——{@link MerchantPaymentReadinessService} 找不到已通过的进件就直接判「商户进件尚未审核
     * 通过,暂时无法收款」。所以记录没了,权限也就没了,不需要再动商户端登录账号(那是登录,不是收款)。
     */
    @Transactional
    public Map<String, Object> unbind(Long applicationId) {
        List<Map<String, Object>> applications = rows(
                "select * from merchant_onboarding_application where id = ? limit 1 for update", applicationId);
        if (applications.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "商户进件不存在");
        Map<String, Object> application = applications.get(0);
        if (!MerchantOnboardingProgressService.isManaged(application)) {
            throw new BusinessException(ErrorCode.CONFLICT, "这条进件来自商户端自助进件,不支持解绑(解绑只针对后台纳管的外部进件商户)");
        }
        String blocker = unbindBlocker(application);
        if (!blocker.isBlank()) throw new BusinessException(ErrorCode.CONFLICT, blocker);
 
        Object id = application.get("id");
        // 进度、尝试记录都是这条进件的从属行,一起清掉;否则重新纳管时旧证据会被新记录误读。
        jdbc.update("delete from merchant_onboarding_attempt where application_id = ?", id);
        jdbc.update("delete from merchant_onboarding_progress where application_id = ?", id);
        // 结算卡按进件上绑的那一张删:纳管只建这一张,其余卡(商户后来自己加过的话)不属于这次绑定。
        jdbc.update("delete from merchant_bank_card where id = ? and merchant_id = ?",
                application.get("initialBankCardId"), application.get("merchantId"));
        jdbc.update("delete from merchant_onboarding_application where id = ?", id);
 
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("unbound", true);
        result.put("merchantId", String.valueOf(application.get("merchantId")));
        result.put("providerHuifuId", clean(application.get("providerHuifuId")));
        // 解绑后可重新纳管:商户端登录账号、门店与资料都没动。
        result.put("message", "已解除我方绑定。通道侧的配置(微信口径、人才端绑定、延迟入账、实名)不会被回退,商户可重新纳管。");
        return result;
    }
 
    /** 纳管完成后跑一次进度刷新;只有它把申请收口成通过时,能力快照才有东西可核实。 */
    private void refreshCapabilityWhenApproved(Long applicationId) {
        List<Map<String, Object>> rows = rows("select status from merchant_onboarding_application where id = ? limit 1", applicationId);
        if (rows.isEmpty() || !"approved".equals(clean(rows.get(0).get("status")))) return;
        // 能力快照是纯查询(商户详细信息查询),与「只查询不下发」不冲突;它是收款闸门读的那几个字段。
        readinessService.refreshQuietly(applicationId);
    }
 
    /**
     * 只读核验通道商户:用我们自己的签名身份查一次商户详细信息。
     *
     * <p>这个接口同时是「商户注册名称 / 商户简称 / 主体类型」与「结算卡列表 + 绑卡序列号」的来源,
     * 所以纳管表单一共只有三个字段,卡号从通道查回来、不需要运营填(回执里的卡号是明文,见
     * {@link MerchantOnboardingProgressService} 里按卡号哈希匹配绑卡的原注释)。
     */
    private Map<String, Object> channelMerchant(String huifuId) {
        Map<String, Object> detail;
        try {
            detail = providerGateway.queryCapabilities(huifuId);
        } catch (RuntimeException exception) {
            throw new BusinessException(ErrorCode.CONFLICT, "通道查询失败,暂时无法核验该商户,请稍后重试");
        }
        if (detail == null || !"00000000".equals(clean(detail.get("resp_code")))) {
            String description = detail == null ? "" : clean(detail.get("resp_desc"));
            throw new BusinessException(ErrorCode.CONFLICT, "通道查不到该商户("
                    + (description.isBlank() ? "无返回描述" : description)
                    + "):它不在我们渠道商的管辖范围内,纳管无法进行");
        }
        if (clean(detail.get("reg_name")).isBlank()) {
            throw new BusinessException(ErrorCode.CONFLICT, "通道未返回该商户的注册名称,无法人工核对主体,纳管无法进行");
        }
        return detail;
    }
 
    /**
     * 通道的结算卡列表里挑一张能用的卡。
     *
     * <p>优先级:挂在结算配置上的默认卡 → 挂在结算配置上的卡 → 任意一张有明文的卡。必须挑到与结算
     * 配置对得上的那张:分阶段进度的银行卡阶段就是按「卡号哈希 + 绑卡序列号 + 已挂结算配置」核实的,
     * 挑错了这张卡永远核实不过,进件也就永远收不了口。
     */
    private Map<String, Object> pickSettlementCard(Map<String, Object> channel) {
        List<String> settleTokens = providerList(channel.get("qry_settle_config_list")).stream()
                .map(item -> clean(item.get("token_no"))).filter(token -> !token.isBlank()).toList();
        Map<String, Object> linkedDefault = null;
        Map<String, Object> linked = null;
        Map<String, Object> fallback = null;
        for (Map<String, Object> card : providerList(channel.get("qry_cash_card_info_list"))) {
            // 通道回执里的卡号是明文;掩码(含 *)不算,否则建出来的卡哈希永远对不上。
            if (!clean(card.get("card_no")).matches("[0-9]{8,32}")) continue;
            // N 正常 / C 关闭:已关闭的卡建出来只会让银行卡阶段永远核实不过。
            if ("C".equalsIgnoreCase(clean(card.get("status")))) continue;
            if (settleTokens.contains(clean(card.get("token_no")))) {
                if ("Y".equalsIgnoreCase(clean(card.get("is_settle_default")))) linkedDefault = card;
                else if (linked == null) linked = card;
            } else if (fallback == null) fallback = card;
        }
        if (linkedDefault != null) return linkedDefault;
        return linked != null ? linked : fallback;
    }
 
    /**
     * 建结算卡记录,沿用现有的字段级加密与卡号哈希,不新造一套(卡哈希就是进度核实与绑卡去重的依据)。
     */
    private Long insertSettlementCard(MerchantRef merchant, Long storeId, String huifuId,
                                     Map<String, Object> card, String cardNumber) {
        Long merchantId = merchant.id();
        String cardKey = managedBankCardIdempotencyKey(huifuId);
        // 卡哈希在 (merchant_id, card_hash) 上唯一,幂等键在 (merchant_id, idempotency_key) 上唯一;
        // 同一商家重复纳管会先被进件那一侧的幂等拦掉,走不到这里。
        jdbc.update("""
                insert into merchant_bank_card(merchant_id, store_id, bank_name, branch_name, branch_code, city_code,
                                               card_tail, card_masked, card_hash, card_no_encrypted, provider_token_no,
                                               provider_response_message, idempotency_key, status, reviewed_at)
                values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'approved', now())
                """, merchantId, storeId, cardBankName(card, merchant), cardBranchName(card), clean(card.get("branch_code")),
                clean(card.get("area_id")), cardNumber.substring(cardNumber.length() - 4), maskedCardNumber(cardNumber),
                sha256(cardNumber), sensitiveDataCrypto.encrypt(cardNumber), clean(card.get("token_no")),
                "纳管自汇付侧已进件商户,结算卡由通道查询反填", cardKey);
        return jdbc.queryForObject("select id from merchant_bank_card where merchant_id = ? and idempotency_key = ?",
                Long.class, merchantId, cardKey);
    }
 
    /**
     * 建进件记录:非空字段(主体类型、主体名、联系人、联系电话、申请单号、幂等键)全部填满,
     * 通道能反填的用通道的,拿不到的从我们的商家记录取,不留空。
     */
    private Long insertApplication(MerchantRef merchant, Long storeId, String huifuId, String applyNo,
                                   String applicationNo, String idempotencyKey, Map<String, Object> channel, Long bankCardId) {
        Long merchantId = merchant.id();
        String subjectType = subjectTypeOf(channel);
        String channelName = clean(channel.get("reg_name"));
        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")), merchant.contactName(),
                clean(channel.get("legal_name")), merchantName);
        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"));
        String companyType = "enterprise".equals(subjectType)
                ? firstNonBlank(COMPANY_TYPE_BY_ENT_TYPE.getOrDefault(clean(channel.get("ent_type")), ""), "私营企业")
                // 个人主体的 company_type 是经营行业(决定 MCC,而 MCC 在自助进件那边才下发),
                // 纳管不下发,所以这里取我们商家档案上的经营类型,与「行业以系统档案为准」口径一致。
                : 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,
                    subject_id_no_masked, subject_id_no_encrypted, contact_name, contact_mobile, legal_mobile,
                    administrator_email, legal_certificate_address, registered_province_code, registered_city_code,
                    registered_district_code, registered_address, business_address, provider_huifu_id,
                    provider_apply_no, initial_bank_card_id, idempotency_key, source, status)
                values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
                """, merchantId, storeId, applicationNo, subjectType, subjectName,
                firstNonBlank(channelName, merchantName), clean(channel.get("short_name")), companyType,
                clean(channel.get("license_code")), maskIdNumber(idNumber),
                idNumber.isBlank() ? null : sensitiveDataCrypto.encrypt(idNumber), contactName, contactMobile,
                legalMobile, clean(channel.get("contact_email")), clean(channel.get("legal_addr")),
                clean(channel.get("reg_prov_id")), clean(channel.get("reg_city_id")), clean(channel.get("reg_district_id")),
                firstNonBlank(clean(channel.get("reg_addr")), clean(channel.get("reg_detail"))),
                clean(channel.get("detail_addr")), huifuId, applyNo, bankCardId, idempotencyKey,
                MerchantOnboardingProgressService.SOURCE_MANAGED);
        return jdbc.queryForObject("select id from merchant_onboarding_application where application_no = ?", Long.class, applicationNo);
    }
 
    /**
     * 解绑的硬拦截:该绑定上还有在途订单或待结算资金时拒绝并说明。
     *
     * <p>平台本地**没有结算账本**(V140 已把提现/结算记录整体退役,钱怎么结是斗拱按周期自动做的),
     * 所以「待结算资金」只能用订单状态推:已收款且未终态退款的订单,钱还在通道侧(延迟户或基本户)
     * 等结算,那就是待结算资金。已取消、已退款的订单是终态,不拦。
     *
     * <p>范围按订单上记录的收款绑定(V131 的 {@code collect_onboarding_id})划,不按汇付商户号划:
     * 连锁多店共用同一个商户号,按商户号划会让 A 店的订单把 B 店的解绑也拦死。历史行没有这个字段时
     * 回落到「同商家同门店」。
     */
    private String unbindBlocker(Map<String, Object> application) {
        List<Map<String, Object>> counts = rows("""
                select
                    sum(case when o.order_status = 0 then 1 else 0 end) as unpaid,
                    sum(case when o.order_status = 1 then 1 else 0 end) as unsettled
                from benefit_order o
                where o.deleted = 0 and o.pay_amount > 0 and o.order_status in (0, 1) and o.refund_status <> 2
                  and (o.collect_onboarding_id = ?
                       or (o.collect_onboarding_id is null and o.merchant_id = ? and o.store_id = ?))
                """, application.get("id"), application.get("merchantId"), application.get("storeId"));
        long unpaid = count(counts, "unpaid");
        long unsettled = count(counts, "unsettled");
        if (unsettled > 0) return "该商家名下还有 " + unsettled + " 笔已收款未结算的订单,解绑会切断正在进行的对账,请等资金结算完成后再解绑";
        if (unpaid > 0) return "该商家名下还有 " + unpaid + " 笔在途订单(待支付),请等这些订单关闭后再解绑";
        return "";
    }
 
    /**
     * 该门店是否已经有一条进行中或已通过的进件。
     *
     * <p>这与「重复纳管同一商家幂等」是两件事:幂等防的是重复点击,这里防的是同一门店出现两个收款
     * 主体——两条都会在下单时打架(下单闸门取的是最新那条已通过的进件)。
     */
    private String activeConflict(Long merchantId, Long storeId) {
        Map<String, Object> active = activeApplication(merchantId, storeId);
        return active == null ? "" : conflictMessage(active);
    }
 
    /** 该门店当前进行中或已通过的进件;没有则返回 null。 */
    private Map<String, Object> activeApplication(Long merchantId, Long storeId) {
        // 只看「有结算卡」的那类进件:进度只接管已关联结算卡的申请,没有卡就永远收不了口、也永远过不了
        // 下单闸门,因此它不构成第二个收款主体。把它当成硬拦只会把运营卡死——那种记录连解绑都做不了
        // (解绑只面向纳管记录)。判定与收款闸门、进度刷新读的是同一个条件。
        List<Map<String, Object>> active = rows("""
                select application_no, status, source, provider_huifu_id from merchant_onboarding_application
                where merchant_id = ? and store_id = ? and initial_bank_card_id is not null
                  and status in ('pending', 'approved')
                order by id desc limit 1
                """, merchantId, storeId);
        return active.isEmpty() ? null : active.get(0);
    }
 
    private String conflictMessage(Map<String, Object> active) {
        String origin = MerchantOnboardingProgressService.isManaged(active) ? "纳管的" : "自助进件的";
        return "该门店已有一条" + ("approved".equals(clean(active.get("status"))) ? "已通过" : "进行中")
                + "的" + origin + "进件(" + clean(active.get("applicationNo")) + "),请先处理它或解绑,避免同一门店出现两个收款主体";
    }
 
    /** 该汇付商户号已经绑在哪些其他商家上。只提示不拦截,所以要连商家名带门店名一起列出来。 */
    private List<String> boundMerchantNames(String huifuId, Long exceptMerchantId) {
        return rows("""
                select m.merchant_name, s.store_name from merchant_onboarding_application a
                join merchant m on m.id = a.merchant_id
                left join merchant_store s on s.id = a.store_id
                where a.provider_huifu_id = ? and a.merchant_id <> ? and a.status in ('pending', 'approved')
                order by a.id
                """, huifuId, exceptMerchantId).stream()
                .map(row -> displayName(clean(row.get("merchantName")), clean(row.get("storeName"))))
                .distinct().toList();
    }
 
    /**
     * 「商家名(门店名)」的展示形式。门店名与商家名一样时不再重复一遍——多数门店名就是商家名,
     * 加上去只是把同一句话印两遍(列表里看起来像「某某商户(某某商户)」)。
     */
    private static String displayName(String merchantName, String storeName) {
        return storeName.isBlank() || storeName.equals(merchantName)
                ? merchantName : merchantName + "(" + storeName + ")";
    }
 
    /**
     * 通道侧两个名称与所选商家名**连一个字符都不重叠**时的警示。
     *
     * <p>刻意不做「名称是否对得上」的判定:形态不对应(工商全称 vs 品牌简称),规则化匹配必然误判。
     * 只把「完全不像同一个主体」这个确定性事实说出来,既不硬拒也不错放。
     */
    static String nameWarning(String ourName, String channelName, String channelShortName) {
        if (ourName.isBlank()) return "";
        if (!sharesNoCharacter(ourName, channelName) || !sharesNoCharacter(ourName, channelShortName)) return "";
        return "通道侧名称(" + channelName + " / " + channelShortName + ")与所选商家名(" + ourName
                + ")没有任何相同字符,请确认选对了商家;确实可能是简称、别名或服务点名。";
    }
 
    /** 商家名里每个非空白字符都不在通道名称里出现,即「连一个字符都不重叠」。 */
    private static boolean sharesNoCharacter(String ourName, String channelName) {
        if (channelName.isBlank()) return false;
        for (char character : ourName.trim().toCharArray()) {
            if (!Character.isWhitespace(character) && channelName.indexOf(character) >= 0) return false;
        }
        return true;
    }
 
    /**
     * 纳管不建商户端登录账号(账号由「商家查询」的商家编辑负责),但商户登不进来要让运营知道。
     */
    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, merchant.id());
        return accounts == null || accounts == 0
                ? "该商家还没有商户端登录账号,纳管不会为它创建;请到「商家查询」为该商家编辑并设置登录账号,否则商户登不进来。"
                : "";
    }
 
    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, "商家不存在");
        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);
        if (value.isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "请填写汇付商户号");
        return value;
    }
 
    /**
     * 纳管的门店:进件记录的 store_id 非空,而纳管表单只选商家;取该商家最早的那个可用门店。
     *
     * <p>连锁按「每个门店建成一个独立商家」实现(不做「一个商家挂多个门店」),所以一个商家对应一个
     * 收款主体,取哪个门店都不改变业务含义;取最早那个是为了让重复纳管派生出的键稳定。
     */
    private Long primaryStore(Long merchantId) {
        List<Map<String, Object>> stores = rows(
                "select id from merchant_store where merchant_id = ? and status = 1 and deleted = 0 order by id asc limit 1",
                merchantId);
        if (stores.isEmpty()) throw new BusinessException(ErrorCode.CONFLICT, "该商家没有可用门店,无法纳管");
        return ((Number) stores.get(0).get("id")).longValue();
    }
 
    private String storeName(Long storeId) {
        List<Map<String, Object>> stores = rows("select store_name from merchant_store where id = ? limit 1", storeId);
        return stores.isEmpty() ? "" : clean(stores.get(0).get("storeName"));
    }
 
    /**
     * 由汇付商户号派生进件键,使重复纳管稳定幂等。
     *
     * <p>{@code application_no} 是全局唯一键、{@code idempotency_key} 在 (merchant_id, key) 上唯一
     * (见 V80)。商户号可以合法地被多个商家共用,所以门店号必须进 application_no,否则第二个商家
     * 纳管同一个商户号时会撞唯一键。
     */
    static String managedApplicationNo(String huifuId, Long storeId) {
        return "MG" + huifuId + "-" + storeId;
    }
 
    static String managedIdempotencyKey(String huifuId) {
        return "managed:" + huifuId;
    }
 
    static String managedBankCardIdempotencyKey(String huifuId) {
        return "managed-card:" + huifuId;
    }
 
    /** 通道的 {@code cust_type}:1 企业 / 2 个人。只认 1 是企业,取值缺失按个人处理只会造出错误的主体类型。 */
    private static String subjectTypeOf(Map<String, Object> channel) {
        return "1".equals(clean(channel.get("cust_type"))) ? "enterprise" : "individual";
    }
 
    private String cardBankName(Map<String, Object> card, MerchantRef merchant) {
        return firstNonBlank(clean(card.get("bank_name")), merchant.name());
    }
 
    /** 对私结算的支行名不一定有值,但该列非空:拿不到就回落总行名,不硬造。 */
    private String cardBranchName(Map<String, Object> card) {
        return firstNonBlank(clean(card.get("branch_name")), clean(card.get("bank_name")));
    }
 
    private static String maskedCardNumber(String cardNumber) {
        return "**** **** **** " + cardNumber.substring(cardNumber.length() - 4);
    }
 
    private static String maskIdNumber(String idNumber) {
        return idNumber.length() < 8 ? "" : idNumber.substring(0, 3) + "********" + idNumber.substring(idNumber.length() - 4);
    }
 
    private static long count(List<Map<String, Object>> rows, String column) {
        if (rows.isEmpty()) return 0L;
        Object value = rows.get(0).get(column);
        return value instanceof Number number ? number.longValue() : 0L;
    }
 
    private static String firstNonBlank(String... values) {
        for (String value : values) if (value != null && !value.isBlank()) return value.trim();
        return "";
    }
 
    private static String sha256(String value) {
        try {
            return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
        } catch (java.security.NoSuchAlgorithmException exception) {
            throw new IllegalStateException("Unable to hash settlement card", exception);
        }
    }
 
    /** 通道返回的列表字段可能是 JSON 字符串也可能是已解析的数组,两种都要能吃。 */
    @SuppressWarnings("unchecked")
    private static List<Map<String, Object>> providerList(Object raw) {
        if (raw == null) return List.of();
        Object parsed = raw instanceof String value ? (value.isBlank() ? null : com.alibaba.fastjson.JSON.parse(value)) : raw;
        if (!(parsed instanceof List<?> list)) return List.of();
        return list.stream().filter(Map.class::isInstance).map(item -> (Map<String, Object>) item).toList();
    }
 
    private List<Map<String, Object>> rows(String sql, Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql, args)); }
 
    private static String clean(Object value) { return value == null ? "" : String.valueOf(value).trim(); }
}