yeg
2026-09-12 41cf4fe69e47b79de8208a06815e74895d74777a
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
package com.talentcard.server.business.merchant;
 
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
 
import com.talentcard.server.business.BusinessIdGenerator;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
import com.talentcard.server.business.payment.huifu.HuifuIndividualOnboardingCommand;
import com.talentcard.server.business.payment.huifu.HuifuIndividualOnboardingGateway;
import com.talentcard.server.business.payment.huifu.HuifuEnterpriseOnboardingCommand;
import com.talentcard.server.business.payment.huifu.HuifuEnterpriseOnboardingGateway;
import com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway;
import com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway;
import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException;
import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties;
import com.talentcard.server.business.payment.huifu.HuifuPictureUploadGateway;
import com.talentcard.server.business.payment.huifu.HuifuUserBusinessOpenCommand;
import com.talentcard.server.business.payment.huifu.HuifuUserBusinessOpenGateway;
import com.talentcard.server.common.SensitiveDataCrypto;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
@Service
public class MerchantSettlementService {
    private static final Map<String, String> MCC_BY_MERCHANT_TYPE = Map.ofEntries(
            Map.entry("food", "5812"), Map.entry("scenic", "7991"), Map.entry("hotel", "7011"), Map.entry("apartment", "7011"),
            Map.entry("cinema", "7832"), Map.entry("fuel", "5541"), Map.entry("parking", "7523"), Map.entry("carWash", "7542"),
            Map.entry("medical", "8099"), Map.entry("employment", "7361"), Map.entry("finance", "6012"), Map.entry("approval", "9399"),
            Map.entry("children", "8351"), Map.entry("housing", "6513"), Map.entry("car", "5511"), Map.entry("appliance", "5722"),
            Map.entry("digital", "5732"), Map.entry("culture", "5942"), Map.entry("telecom", "4814"), Map.entry("dental", "8021"),
            Map.entry("shopping", "5311"), Map.entry("other", "7399"));
    private final JdbcTemplate jdbc;
    private final SensitiveDataCrypto sensitiveDataCrypto;
    private final HuifuIndividualOnboardingGateway individualOnboardingGateway;
    private final HuifuEnterpriseOnboardingGateway enterpriseOnboardingGateway;
    private final HuifuMerchantWechatConfigGateway merchantWechatConfigGateway;
    private final HuifuMerchantBusinessOpenGateway merchantBusinessOpenGateway;
    private final HuifuMerchantWithdrawalService withdrawalService;
    private final HuifuUserBusinessOpenGateway userBusinessOpenGateway;
    private final HuifuPaymentProperties huifuPaymentProperties;
    private final HuifuPictureUploadGateway pictureUploadGateway;
    private final MerchantPaymentReadinessService paymentReadinessService;
 
    public MerchantSettlementService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, HuifuIndividualOnboardingGateway individualOnboardingGateway, HuifuEnterpriseOnboardingGateway enterpriseOnboardingGateway, HuifuMerchantWechatConfigGateway merchantWechatConfigGateway, HuifuMerchantBusinessOpenGateway merchantBusinessOpenGateway, HuifuMerchantWithdrawalService withdrawalService, HuifuUserBusinessOpenGateway userBusinessOpenGateway, HuifuPaymentProperties huifuPaymentProperties, HuifuPictureUploadGateway pictureUploadGateway, MerchantPaymentReadinessService paymentReadinessService) {
        this.jdbc = jdbc;
        this.sensitiveDataCrypto = sensitiveDataCrypto;
        this.individualOnboardingGateway = individualOnboardingGateway;
        this.enterpriseOnboardingGateway = enterpriseOnboardingGateway;
        this.merchantWechatConfigGateway = merchantWechatConfigGateway;
        this.merchantBusinessOpenGateway = merchantBusinessOpenGateway;
        this.withdrawalService = withdrawalService;
        this.userBusinessOpenGateway = userBusinessOpenGateway;
        this.huifuPaymentProperties = huifuPaymentProperties;
        this.pictureUploadGateway = pictureUploadGateway;
        this.paymentReadinessService = paymentReadinessService;
    }
 
    @Transactional(readOnly = true)
    public Map<String, Object> onboardingStatus(MerchantPrincipal principal) {
        store(principal);
        List<Map<String, Object>> records = rows("select * from merchant_onboarding_application where merchant_id = ? and store_id = ? and initial_bank_card_id is not null order by id desc limit 1", principal.merchantId(), principal.storeId());
        return records.isEmpty() ? Map.of("status", "unsubmitted", "reason", "") : onboarding(records.get(0));
    }
 
    @Transactional
    public Map<String, Object> submitOnboarding(MerchantPrincipal principal, MerchantOnboardingRequest request, String key) {
        requireKey(key); store(principal);
        List<Map<String, Object>> repeated = rows("select * from merchant_onboarding_application where merchant_id = ? and idempotency_key = ? limit 1", principal.merchantId(), key.trim());
        if (!repeated.isEmpty()) return onboarding(repeated.get(0));
        List<Map<String, Object>> active = rows("select * 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", principal.merchantId(), principal.storeId());
        if (!active.isEmpty()) return onboarding(active.get(0));
        boolean enterprise = "enterprise".equals(request.subjectType());
        if (enterprise && (request.creditCode() == null || !request.creditCode().trim().matches("[0-9A-Z]{18}"))) throw new BusinessException(ErrorCode.BAD_REQUEST, "请输入正确的18位统一社会信用代码");
        if (request.subjectIdNumber() == null || !request.subjectIdNumber().trim().matches("(?:\\d{15}|\\d{17}[0-9X])")) throw new BusinessException(ErrorCode.BAD_REQUEST, enterprise ? "请输入正确的法人身份证号" : "请输入正确的负责人身份证号");
        requireCertificatePeriod(request.subjectCertificateValidityType(), request.subjectCertificateBeginDate(), request.subjectCertificateEndDate(), "Identity document");
        if (blank(request.legalCertificateAddress())) throw new BusinessException(ErrorCode.BAD_REQUEST, enterprise ? "请输入法人证件地址" : "请输入负责人证件地址");
        if (blank(request.administratorEmail()) || !request.administratorEmail().trim().matches("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$")) throw new BusinessException(ErrorCode.BAD_REQUEST, "请输入正确的联系人邮箱");
        if (enterprise) {
            requireCertificatePeriod(request.licenseValidityType(), request.licenseBeginDate(), request.licenseEndDate(), "Business licence");
            if (blank(request.registeredProvinceCode()) || blank(request.registeredCityCode()) || blank(request.registeredDistrictCode())) throw new BusinessException(ErrorCode.BAD_REQUEST, "Enterprise registration address codes are required");
            if (blank(request.merchantName()) || blank(request.businessLicenseUrl()) || blank(request.legalIdFrontUrl()) || blank(request.legalIdBackUrl()) || blank(request.establishedDate())) throw new BusinessException(ErrorCode.BAD_REQUEST, "请补全企业进件资料");
            normalizeRegisteredCapital(request.companyType(), request.registeredCapital());
        } else {
            if (blank(request.registeredDistrictCode()) || blank(request.registeredAddress()) || blank(request.legalIdFrontUrl()) || blank(request.legalIdBackUrl())) throw new BusinessException(ErrorCode.BAD_REQUEST, "请补全个人商户进件资料");
        }
        boolean privateSettlement = !enterprise || "个体工商户".equals(clean(request.companyType()));
        if (privateSettlement && blank(request.settlementCardFrontUrl())) throw new BusinessException(ErrorCode.BAD_REQUEST, "请上传结算银行卡卡号面");
        if (!privateSettlement && blank(request.registrationAccountUrl())) throw new BusinessException(ErrorCode.BAD_REQUEST, "请上传开户许可证");
        List<Map<String, Object>> branchRows = rows("select clearing_code, branch_name, bank_name, head_bank_code from bank_branch_directory where clearing_code = ? limit 1", request.bankBranchCode().trim());
        if (branchRows.isEmpty()) throw new BusinessException(ErrorCode.BAD_REQUEST, "请选择真实开户行");
        Map<String, Object> branch = branchRows.get(0);
        if (clean(branch.get("headBankCode")).isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "所选开户行缺少汇付银行编码");
        String no = BusinessIdGenerator.next("MO");
        String certificateValidityType = certificateValidityType(request.subjectCertificateValidityType());
        String certificateBeginDate = certificateBeginDate(request.subjectCertificateBeginDate());
        jdbc.update("insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name, credit_code, license_validity_type, license_begin_date, license_end_date, subject_id_no_masked, subject_id_no_encrypted, subject_cert_validity_type, subject_cert_begin_date, subject_cert_end_date, registered_region, registered_province_code, registered_city_code, registered_district_code, registered_address, merchant_name, merchant_short_name, company_type, business_license_url, established_date, registered_capital, legal_id_front_url, legal_id_back_url, legal_certificate_address, administrator_email, contact_name, contact_mobile, idempotency_key) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", principal.merchantId(), principal.storeId(), no, request.subjectType(), request.subjectName().trim(), clean(request.creditCode()), certificateValidityType(request.licenseValidityType()), certificateBeginDate(request.licenseBeginDate()), certificateEndDate(request.licenseEndDate()), maskId(request.subjectIdNumber()), sensitiveDataCrypto.encrypt(clean(request.subjectIdNumber()).toUpperCase()), certificateValidityType, certificateBeginDate, certificateEndDate(request.subjectCertificateEndDate()), clean(request.registeredRegion()), clean(request.registeredProvinceCode()), clean(request.registeredCityCode()), clean(request.registeredDistrictCode()), clean(request.registeredAddress()), clean(request.merchantName()), clean(request.merchantShortName()), clean(request.companyType()), clean(request.businessLicenseUrl()), clean(request.establishedDate()), clean(request.registeredCapital()), clean(request.legalIdFrontUrl()), clean(request.legalIdBackUrl()), clean(request.legalCertificateAddress()), clean(request.administratorEmail()), request.contactName().trim(), request.contactMobile().trim(), key.trim());
        String cardNumber = request.bankCardNumber().trim();
        String cardKey = key.trim() + ":initial";
        if (cardKey.length() > 128) cardKey = cardKey.substring(0, 128);
        String cardHash = sha256(cardNumber);
        List<Map<String, Object>> existingCards = rows("select * from merchant_bank_card where merchant_id = ? and card_hash = ? limit 1 for update", principal.merchantId(), cardHash);
        Long bankCardId;
        if (!existingCards.isEmpty()) {
            Map<String, Object> existing = existingCards.get(0);
            String existingStatus = clean(existing.get("status"));
            Long existingCardId = ((Number) existing.get("id")).longValue();
            List<Map<String, Object>> linkedActiveOnboardings = rows("select id from merchant_onboarding_application where initial_bank_card_id = ? and status in ('pending','approved') limit 1", existingCardId);
            if ("pending".equals(existingStatus) || !linkedActiveOnboardings.isEmpty()) {
                throw new BusinessException(ErrorCode.CONFLICT, "该银行卡已有审核中的进件或已通过的申请,请勿重复提交");
            }
            bankCardId = existingCardId;
            jdbc.update("update merchant_bank_card set store_id=?, bank_name=?, branch_name=?, branch_code=?, city_code=?, card_tail=?, card_masked=?, card_no_encrypted=?, provider_request_no=?, idempotency_key=?, reject_reason=null, reviewed_at=null, provider_token_no=null, provider_apply_no=null, provider_response_code=null, provider_response_message=null, status='pending' where id=?", principal.storeId(), clean(branch.get("bankName")), clean(branch.get("branchName")), clean(branch.get("clearingCode")), request.bankCityCode().trim(), cardNumber.substring(cardNumber.length() - 4), "**** **** **** " + cardNumber.substring(cardNumber.length() - 4), sensitiveDataCrypto.encrypt(cardNumber), no, cardKey, bankCardId);
        } else {
            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_request_no, idempotency_key) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", principal.merchantId(), principal.storeId(), clean(branch.get("bankName")), clean(branch.get("branchName")), clean(branch.get("clearingCode")), request.bankCityCode().trim(), cardNumber.substring(cardNumber.length() - 4), "**** **** **** " + cardNumber.substring(cardNumber.length() - 4), cardHash, sensitiveDataCrypto.encrypt(cardNumber), no, cardKey);
            bankCardId = ((Number) rows("select id from merchant_bank_card where merchant_id=? and idempotency_key=?", principal.merchantId(), cardKey).get(0).get("id")).longValue();
        }
        jdbc.update("update merchant_onboarding_application set initial_bank_card_id=? where application_no=?", bankCardId, no);
        if ("individual".equals(request.subjectType())) submitIndividualOnboarding(principal, no, bankCardId, request, branch);
        else submitEnterpriseOnboarding(principal, no, bankCardId, request, branch, privateSettlement);
        return onboarding(rows("select * from merchant_onboarding_application where application_no = ?", no).get(0));
    }
 
    private void submitEnterpriseOnboarding(MerchantPrincipal principal, String applicationNo, Long bankCardId, MerchantOnboardingRequest request, Map<String, Object> branch, boolean privateSettlement) {
        try {
            String licensePictureId = pictureUploadGateway.uploadUrl(request.businessLicenseUrl(), "F07");
            String legalFrontPictureId = pictureUploadGateway.uploadUrl(request.legalIdFrontUrl(), "F02");
            String legalBackPictureId = pictureUploadGateway.uploadUrl(request.legalIdBackUrl(), "F03");
            String registrationAccountPictureId = privateSettlement ? "" : pictureUploadGateway.uploadUrl(request.registrationAccountUrl(), "F08");
            String settlementCardPictureId = privateSettlement ? pictureUploadGateway.uploadUrl(request.settlementCardFrontUrl(), "F13") : "";
            String settlementCertFrontPictureId = privateSettlement ? pictureUploadGateway.uploadUrl(request.legalIdFrontUrl(), "F55") : "";
            String settlementCertBackPictureId = privateSettlement ? pictureUploadGateway.uploadUrl(request.legalIdBackUrl(), "F56") : "";
            String shortName = blank(request.merchantShortName()) ? request.merchantName().trim() : request.merchantShortName().trim();
            HuifuEnterpriseOnboardingGateway.Result result = enterpriseOnboardingGateway.create(new HuifuEnterpriseOnboardingCommand(
                    applicationNo, clean(request.merchantName()), shortName, request.creditCode().trim(),
                    certificateValidityType(request.licenseValidityType()), certificateBeginDate(request.licenseBeginDate()), certificateEndDate(request.licenseEndDate()),
                    clean(request.registeredProvinceCode()), clean(request.registeredCityCode()), clean(request.registeredDistrictCode()), clean(request.registeredAddress()), request.subjectName().trim(),
                    clean(request.subjectIdNumber()).toUpperCase(), certificateValidityType(request.subjectCertificateValidityType()),
                    certificateBeginDate(request.subjectCertificateBeginDate()), certificateEndDate(request.subjectCertificateEndDate()), clean(request.legalCertificateAddress()), request.contactName().trim(), request.contactMobile().trim(), request.administratorEmail().trim(),
                    entType(request.companyType()), mcc(principal.merchantId()), licensePictureId, legalFrontPictureId, legalBackPictureId,
                    privateSettlement ? "1" : "0", privateSettlement ? request.subjectName().trim() : request.merchantName().trim(), request.bankCardNumber().trim(), request.bankProvinceCode().trim(), request.bankCityCode().trim(), clean(branch.get("headBankCode")), clean(branch.get("clearingCode")), clean(branch.get("branchName")),
                    privateSettlement ? request.subjectIdNumber().trim().toUpperCase() : "", privateSettlement ? certificateValidityType(request.subjectCertificateValidityType()) : "", privateSettlement ? certificateBeginDate(request.subjectCertificateBeginDate()) : "", privateSettlement ? certificateEndDate(request.subjectCertificateEndDate()) : "", privateSettlement ? request.contactMobile().trim() : "",
                    registrationAccountPictureId, settlementCardPictureId, settlementCertFrontPictureId, settlementCertBackPictureId,
                    certificateBeginDate(request.establishedDate()), normalizeRegisteredCapital(request.companyType(), request.registeredCapital())));
            applyOnboardingResult(applicationNo, bankCardId, result.accepted(), result.huifuId(), result.applyNo(), result.tokenNo(), result.code(), result.message());
        } catch (HuifuPaymentGatewayException exception) {
            jdbc.update("update merchant_onboarding_application set reject_reason=? where application_no=? and status='pending'", "Huifu submission outcome is pending confirmation", applicationNo);
        }
    }
 
    private void submitIndividualOnboarding(MerchantPrincipal principal, String applicationNo, Long bankCardId, MerchantOnboardingRequest request, Map<String, Object> branch) {
        try {
            String legalFrontPictureId = pictureUploadGateway.uploadUrl(request.legalIdFrontUrl(), "F40");
            String legalBackPictureId = pictureUploadGateway.uploadUrl(request.legalIdBackUrl(), "F41");
            String settlementCardPictureId = pictureUploadGateway.uploadUrl(request.settlementCardFrontUrl(), "F13");
            HuifuIndividualOnboardingGateway.Result result = individualOnboardingGateway.create(new HuifuIndividualOnboardingCommand(
                    applicationNo, request.subjectName().trim(), request.subjectIdNumber().trim().toUpperCase(), certificateValidityType(request.subjectCertificateValidityType()), certificateBeginDate(request.subjectCertificateBeginDate()), certificateEndDate(request.subjectCertificateEndDate()), request.contactMobile().trim(), clean(request.registeredAddress()), request.administratorEmail().trim(),
                    mcc(principal.merchantId()), clean(request.registeredProvinceCode()), clean(request.registeredCityCode()), clean(request.registeredDistrictCode()), legalFrontPictureId, legalBackPictureId,
                    request.bankCardNumber().trim(), request.bankProvinceCode().trim(), request.bankCityCode().trim(), clean(branch.get("headBankCode")), settlementCardPictureId));
            applyOnboardingResult(applicationNo, bankCardId, result.accepted(), result.huifuId(), result.applyNo(), result.tokenNo(), result.code(), result.message());
        } catch (HuifuPaymentGatewayException exception) {
            jdbc.update("update merchant_onboarding_application set reject_reason=? where application_no=? and status='pending'", "Huifu submission outcome is pending confirmation", applicationNo);
        }
    }
 
    private void applyOnboardingResult(String applicationNo, Long bankCardId, boolean accepted, String huifuId, String applyNo, String tokenNo, String code, String message) {
        if (accepted) {
            boolean completed = !blank(huifuId) && !blank(tokenNo);
            jdbc.update("update merchant_onboarding_application set status='pending', provider_huifu_id=nullif(?,''), provider_apply_no=nullif(?,''), provider_response_code=?, provider_response_message=?, reviewed_at=null, reject_reason=null where application_no=?", clean(huifuId), clean(applyNo), clean(code), clean(message), applicationNo);
            jdbc.update("update merchant_bank_card set status=?, provider_token_no=nullif(?,''), provider_apply_no=nullif(?,''), provider_response_code=?, provider_response_message=?, reviewed_at=?, reject_reason=null where id=?", completed ? "approved" : "pending", clean(tokenNo), clean(applyNo), clean(code), clean(message), completed ? LocalDateTime.now() : null, bankCardId);
            if (completed) continuePostOnboardingProvisioning(applicationNo);
        } else {
            String reason = providerReason(code, message);
            jdbc.update("update merchant_onboarding_application set status='rejected', provider_apply_no=nullif(?,''), provider_response_code=?, provider_response_message=?, reject_reason=?, reviewed_at=now() where application_no=?", clean(applyNo), clean(code), clean(message), reason, applicationNo);
            jdbc.update("update merchant_bank_card set status='rejected', provider_apply_no=nullif(?,''), provider_response_code=?, provider_response_message=?, reject_reason=?, reviewed_at=now() where id=?", clean(applyNo), clean(code), clean(message), reason, bankCardId);
        }
    }
    @Transactional(readOnly = true)
    public List<Map<String, Object>> bankCards(MerchantPrincipal principal) {
        store(principal);
        return rows("select * from merchant_bank_card where merchant_id = ? and store_id = ? order by id desc", principal.merchantId(), principal.storeId()).stream().map(this::bankCard).toList();
    }
 
    @Transactional
    public Map<String, Object> addBankCard(MerchantPrincipal principal, MerchantBankCardRequest request, String key) {
        requireKey(key); store(principal); requireApproved(principal);
        List<Map<String, Object>> repeated = rows("select * from merchant_bank_card where merchant_id = ? and idempotency_key = ? limit 1", principal.merchantId(), key.trim());
        if (!repeated.isEmpty()) return bankCard(repeated.get(0));
        String number = request.cardNumber().trim(); String hash = sha256(number);
        List<Map<String, Object>> branchRecords = rows("select clearing_code, branch_name, bank_name, head_bank_code from bank_branch_directory where clearing_code = ? limit 1", request.branchCode().trim());
        if (branchRecords.isEmpty()) throw new BusinessException(ErrorCode.BAD_REQUEST, "请选择真实开户行");
        Map<String, Object> branch = branchRecords.get(0);
        List<Map<String, Object>> active = rows("select id from merchant_bank_card where merchant_id = ? and card_hash = ? and status in ('pending', 'approved') order by id desc limit 1", principal.merchantId(), hash);
        if (!active.isEmpty()) throw new BusinessException(ErrorCode.CONFLICT, "该银行卡已有审核中或已通过的申请,请勿重复添加");
        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, idempotency_key) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", principal.merchantId(), principal.storeId(), clean(branch.get("bankName")), clean(branch.get("branchName")), clean(branch.get("clearingCode")), request.cityCode().trim(), number.substring(number.length() - 4), "**** **** **** " + number.substring(number.length() - 4), hash, sensitiveDataCrypto.encrypt(number), key.trim());
        Map<String, Object> card = rows("select * from merchant_bank_card where merchant_id = ? and idempotency_key = ? limit 1", principal.merchantId(), key.trim()).get(0);
        submitBankCardToHuifu(principal, card, number, branch);
        return bankCard(rows("select * from merchant_bank_card where id = ?", card.get("id")).get(0));
    }
 
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> bankBranches(String cityCode, String cityName, String keyword, int page, int size) {
        if (jdbc != null) return bankBranchesFromDirectory(cityCode, cityName, keyword, page, size);
        // The payment provider's bank directory should replace this curated fallback during Huifu onboarding.
        List<Map<String, Object>> all = List.of(
                branch("CCB510400", "中国建设银行攀枝花东区支行营业部", "中国建设银行"), branch("CCB510401", "中国建设银行攀枝花分行营业部", "中国建设银行"),
                branch("ICBC510400", "中国工商银行攀枝花东区支行", "中国工商银行"), branch("ABC510400", "中国农业银行攀枝花炳草岗支行", "中国农业银行"));
        String query = clean(keyword).toLowerCase(); int safePage = Math.max(page, 1); int safeSize = Math.min(Math.max(size, 1), 50);
        List<Map<String, Object>> matched = all.stream().filter(value -> query.isEmpty() || (value.get("branchName") + "" + value.get("bankName")).toLowerCase().contains(query)).toList();
        int start = Math.min((safePage - 1) * safeSize, matched.size()); int end = Math.min(start + safeSize, matched.size());
        return new PageResult<>(matched.subList(start, end), safePage, safeSize, matched.size());
    }
 
    private PageResult<Map<String, Object>> bankBranchesFromDirectory(String cityCode, String cityName, String keyword, int page, int size) {
        int safePage = Math.max(page, 1);
        int safeSize = Math.min(Math.max(size, 1), 50);
        StringBuilder where = new StringBuilder(" where 1=1");
        List<Object> params = new ArrayList<>();
        String cityFilter = blank(cityName) ? clean(cityCode) : clean(cityName);
        int citySeparator = cityFilter.lastIndexOf(' ');
        if (citySeparator >= 0 && citySeparator + 1 < cityFilter.length()) cityFilter = cityFilter.substring(citySeparator + 1).trim();
        cityFilter = cityKeyword(cityFilter);
        if (!blank(cityFilter)) {
            where.append(" and (branch_name like ? or bank_name like ?)");
            String match = "%" + cityFilter + "%";
            params.add(match);
            params.add(match);
        }
        if (!blank(keyword)) {
            where.append(" and (branch_name like ? or bank_name like ? or clearing_code = ?)");
            String match = "%" + clean(keyword) + "%";
            params.add(match);
            params.add(match);
            params.add(clean(keyword));
        }
        Long total = jdbc.queryForObject("select count(*) from bank_branch_directory" + where, Long.class, params.toArray());
        List<Map<String, Object>> values = rows("select clearing_code as id, clearing_code as branch_code, branch_name, head_bank_code, bank_name from bank_branch_directory" + where + " order by branch_name limit ? offset ?", appendPaging(params, safeSize, (safePage - 1) * safeSize));
        return new PageResult<>(values, safePage, safeSize, total == null ? 0 : total);
    }
 
    @Transactional(readOnly = true)
    public Map<String, Object> withdrawalSummary(MerchantPrincipal principal) {
        store(principal);
        Long verified = jdbc.queryForObject("select coalesce(sum(pay_amount), 0) from benefit_order where merchant_id = ? and (store_id is null or store_id = ?) and order_status = 1 and verify_status = 1 and deleted = 0", Long.class, principal.merchantId(), principal.storeId());
        Long held = jdbc.queryForObject("select coalesce(sum(amount_cents), 0) from merchant_withdrawal where merchant_id = ? and store_id = ? and status in ('pending','processing','paid')", Long.class, principal.merchantId(), principal.storeId());
        long available = Math.max(0, (verified == null ? 0 : verified) - (held == null ? 0 : held));
        return Map.of("availableAmountCents", available, "frozenAmountCents", held == null ? 0 : held);
    }
 
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> withdrawals(MerchantPrincipal principal, int page, int size) {
        store(principal); int safePage = Math.max(page, 1); int safeSize = Math.min(Math.max(size, 1), 50);
        List<Map<String, Object>> values = rows("select w.*, c.bank_name, c.card_masked from merchant_withdrawal w join merchant_bank_card c on c.id = w.bank_card_id where w.merchant_id = ? and w.store_id = ? order by w.id desc limit ? offset ?", principal.merchantId(), principal.storeId(), safeSize, (safePage - 1) * safeSize).stream().map(this::withdrawal).toList();
        Long total = jdbc.queryForObject("select count(*) from merchant_withdrawal where merchant_id = ? and store_id = ?", Long.class, principal.merchantId(), principal.storeId());
        return new PageResult<>(values, safePage, safeSize, total == null ? 0 : total);
    }
 
    @Transactional
    public Map<String, Object> createWithdrawal(MerchantPrincipal principal, MerchantWithdrawalRequest request, String key) {
        requireKey(key); store(principal); requireApproved(principal);
        List<Map<String, Object>> repeated = rows("select * from merchant_withdrawal where merchant_id = ? and idempotency_key = ? limit 1", principal.merchantId(), key.trim());
        if (!repeated.isEmpty()) return withdrawal(repeated.get(0));
        Long cardId = number(request.bankCardId());
        List<Map<String, Object>> cards = rows("select * from merchant_bank_card where id = ? and merchant_id = ? and store_id = ? and status = 'approved' limit 1", cardId, principal.merchantId(), principal.storeId());
        if (cards.isEmpty()) throw new BusinessException(ErrorCode.CONFLICT, "请选择审核通过的银行卡");
        long available = ((Number) withdrawalSummary(principal).get("availableAmountCents")).longValue();
        if (request.amountCents() > available) throw new BusinessException(ErrorCode.CONFLICT, "提现金额不能超过当前可提金额");
        String no = BusinessIdGenerator.next("MW");
        jdbc.update("insert into merchant_withdrawal(merchant_id, store_id, withdrawal_no, bank_card_id, amount_cents, idempotency_key) values (?, ?, ?, ?, ?, ?)", principal.merchantId(), principal.storeId(), no, cardId, request.amountCents(), key.trim());
        Long withdrawalId = jdbc.queryForObject("select id from merchant_withdrawal where withdrawal_no = ?", Long.class, no);
        withdrawalService.dispatch(withdrawalId);
        return withdrawal(rows("select w.*, c.bank_name, c.card_masked from merchant_withdrawal w join merchant_bank_card c on c.id = w.bank_card_id where w.withdrawal_no = ?", no).get(0));
    }
 
    private void requireApproved(MerchantPrincipal principal) { if (!"approved".equals(onboardingStatus(principal).get("status"))) throw new BusinessException(ErrorCode.CONFLICT, "商户进件尚未审核通过"); }
    private void submitBankCardToHuifu(MerchantPrincipal principal, Map<String, Object> card, String cardNumber, Map<String, Object> branch) {
        List<Map<String, Object>> onboarding = rows("select * from merchant_onboarding_application where merchant_id=? and store_id=? and initial_bank_card_id is not null and status='approved' and provider_huifu_id is not null order by id desc limit 1", principal.merchantId(), principal.storeId());
        if (onboarding.isEmpty()) throw new BusinessException(ErrorCode.CONFLICT, "Huifu merchant onboarding is not approved");
        Map<String, Object> subject = onboarding.get(0);
        String bankCode = clean(branch.get("headBankCode"));
        if (bankCode.isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "The selected bank branch has no Huifu bank code");
        boolean enterprise = "enterprise".equalsIgnoreCase(clean(subject.get("subjectType")));
        String cardName = enterprise ? clean(branch.get("bankName")) : clean(subject.get("subjectName"));
        if (!enterprise && cardName.isBlank()) throw new BusinessException(ErrorCode.CONFLICT, "进件姓名缺失,无法绑定个人银行卡");
        String requestNo = BusinessIdGenerator.next("BC");
        try {
            HuifuUserBusinessOpenGateway.Result result = userBusinessOpenGateway.create(new HuifuUserBusinessOpenCommand(
                    requestNo, clean(subject.get("providerHuifuId")), huifuPaymentProperties.getMerchantId().trim(), enterprise ? "0" : "1", cardName, cardNumber,
                    provinceCode(clean(card.get("cityCode"))), clean(card.get("cityCode")), bankCode, clean(branch.get("clearingCode")), clean(branch.get("branchName")),
                    sensitiveDataCrypto.decrypt(clean(subject.get("subjectIdNoEncrypted"))), certificateValidityType(clean(subject.get("subjectCertValidityType"))), clean(subject.get("subjectCertBeginDate")), clean(subject.get("subjectCertEndDate")), clean(subject.get("contactMobile"))));
            if (result.accepted() && !result.tokenNo().isBlank()) jdbc.update("update merchant_bank_card set status='approved', provider_token_no=?, provider_apply_no=?, provider_request_no=?, provider_response_code=?, provider_response_message=?, reviewed_at=now(), reject_reason=null where id=?", result.tokenNo(), result.applyNo(), requestNo, result.code(), result.message(), card.get("id"));
            else if (result.accepted()) jdbc.update("update merchant_bank_card set status='pending', provider_apply_no=?, provider_request_no=?, provider_response_code=?, provider_response_message=?, reject_reason=null where id=?", result.applyNo(), requestNo, result.code(), result.message(), card.get("id"));
            else jdbc.update("update merchant_bank_card set status='rejected', provider_request_no=?, provider_response_code=?, provider_response_message=?, reject_reason=?, reviewed_at=now() where id=?", requestNo, result.code(), result.message(), providerReason(result.code(), result.message()), card.get("id"));
        } catch (HuifuPaymentGatewayException exception) {
            jdbc.update("update merchant_bank_card set provider_request_no=?, reject_reason=? where id=?", requestNo, "Huifu submission outcome is pending confirmation", card.get("id"));
        }
    }
 
    /**
     * Business-opening (card binding) callback. Only a card that is still pending may change state,
     * so a late or duplicated notification for an already approved or rejected card is ignored.
     */
    @Transactional
    @SuppressWarnings("unchecked")
    public boolean handleBankCardCallback(Map<String, Object> payload) {
        Map<String, Object> data = payload;
        Object nested = payload.get("data");
        if (nested instanceof String value && !value.isBlank()) {
            try { data = com.alibaba.fastjson.JSON.parseObject(value, Map.class); } catch (Exception ignored) { }
        } else if (nested instanceof Map<?, ?> value) data = (Map<String, Object>) value;
        String requestNo = clean(data.get("req_seq_id"));
        if (requestNo.isBlank()) requestNo = clean(payload.get("req_seq_id"));
        if (requestNo.isBlank()) return false;
        List<Map<String, Object>> cards = rows("select * from merchant_bank_card where provider_request_no=? limit 1 for update", requestNo);
        if (cards.isEmpty()) return false;
        Map<String, Object> card = cards.get(0);
        Object cardId = card.get("id");
        if (!"pending".equals(clean(card.get("status")))) return true;
        String tokenNo = clean(data.get("token_no"));
        String code = clean(data.get("sub_resp_code")); if (code.isBlank()) code = clean(data.get("resp_code"));
        String message = clean(data.get("sub_resp_desc")); if (message.isBlank()) message = clean(data.get("resp_desc"));
        String applyNo = clean(data.get("apply_no"));
        if (!tokenNo.isBlank() && providerSuccess(code)) jdbc.update("update merchant_bank_card set status='approved', provider_token_no=?, provider_apply_no=coalesce(nullif(?,''),provider_apply_no), provider_response_code=?, provider_response_message=?, reviewed_at=now(), reject_reason=null where id=? and status='pending'", tokenNo, applyNo, code, message, cardId);
        else if (!providerSuccess(code)) jdbc.update("update merchant_bank_card set status='rejected', provider_response_code=?, provider_response_message=?, reject_reason=?, reviewed_at=now() where id=? and status='pending'", code, message, providerReason(code, message), cardId);
        else jdbc.update("update merchant_bank_card set provider_apply_no=coalesce(nullif(?,''),provider_apply_no), provider_response_code=?, provider_response_message=? where id=? and status='pending'", applyNo, code, message, cardId);
        return true;
    }
 
    /**
     * Onboarding audit callback. The handler is keyed by the application number and only moves an
     * application that is not already decided, so duplicated notifications are no-ops and a late
     * notification cannot overwrite a decided application.
     */
    @Transactional
    @SuppressWarnings("unchecked")
    public boolean handleOnboardingCallback(Map<String, Object> data) {
        Object nested = data.get("data");
        if (nested instanceof String value && !value.isBlank()) {
            try { data = com.alibaba.fastjson.JSON.parseObject(value, Map.class); } catch (Exception ignored) { }
        } else if (nested instanceof Map<?, ?> value) data = (Map<String, Object>) value;
        String requestNo = clean(data.get("req_seq_id"));
        if (requestNo.isBlank()) return false;
        List<Map<String, Object>> applications = rows("select * from merchant_onboarding_application where application_no=? limit 1 for update", requestNo);
        if (applications.isEmpty()) return false;
        Map<String, Object> application = applications.get(0);
        if ("approved".equals(clean(application.get("status"))) || "rejected".equals(clean(application.get("status")))) return true;
        String code = clean(data.get("sub_resp_code")); if (code.isBlank()) code = clean(data.get("resp_code"));
        String message = clean(data.get("audit_desc")); if (message.isBlank()) message = clean(data.get("sub_resp_desc")); if (message.isBlank()) message = clean(data.get("resp_desc"));
        String auditStatus = clean(data.get("audit_status"));
        String huifuId = clean(data.get("huifu_id")); String applyNo = clean(data.get("apply_no")); String tokenNo = clean(data.get("token_no"));
        Long bankCardId = application.get("initialBankCardId") instanceof Number number ? number.longValue() : null;
        if ("Y".equalsIgnoreCase(auditStatus)) {
            jdbc.update("update merchant_onboarding_application set provider_huifu_id=coalesce(nullif(?,''),provider_huifu_id), provider_apply_no=coalesce(nullif(?,''),provider_apply_no), provider_response_code=?, provider_response_message=?, reject_reason=null, reviewed_at=null where application_no=? and status not in ('approved','rejected')", huifuId, applyNo, code, message, requestNo);
            if (bankCardId != null) jdbc.update("update merchant_bank_card set status=case when coalesce(nullif(?,''),provider_token_no) is null then status else 'approved' end, provider_token_no=coalesce(nullif(?,''),provider_token_no), provider_apply_no=coalesce(nullif(?,''),provider_apply_no), provider_response_code=?, provider_response_message=?, reject_reason=null where id=? and status='pending'", tokenNo, tokenNo, applyNo, code, message, bankCardId);
            continuePostOnboardingProvisioning(requestNo);
        } else if ("N".equalsIgnoreCase(auditStatus) || "F".equalsIgnoreCase(auditStatus) || !providerSuccess(code)) {
            String reason = providerReason(code, message);
            jdbc.update("update merchant_onboarding_application set status='rejected', provider_huifu_id=coalesce(nullif(?,''),provider_huifu_id), provider_apply_no=coalesce(nullif(?,''),provider_apply_no), provider_response_code=?, provider_response_message=?, reject_reason=?, reviewed_at=now() where application_no=?", huifuId, applyNo, code, message, reason, requestNo);
            if (bankCardId != null) jdbc.update("update merchant_bank_card set status='rejected', provider_apply_no=coalesce(nullif(?,''),provider_apply_no), provider_response_code=?, provider_response_message=?, reject_reason=?, reviewed_at=now() where id=?", applyNo, code, message, reason, bankCardId);
        } else {
            jdbc.update("update merchant_onboarding_application set provider_huifu_id=coalesce(nullif(?,''),provider_huifu_id), provider_apply_no=coalesce(nullif(?,''),provider_apply_no), provider_response_code=?, provider_response_message=? where application_no=?", huifuId, applyNo, code, message, requestNo);
        }
        return true;
    }
 
    @Transactional
    public boolean handleWechatConfigCallback(Map<String, Object> callback) {
        Map<String, Object> data = callbackData(callback);
        String requestNo = clean(data.get("req_seq_id"));
        if (requestNo.isBlank()) return false;
        List<Map<String, Object>> applications = rows("select * from merchant_onboarding_application where wechat_config_request_no=? limit 1 for update", requestNo);
        if (applications.isEmpty()) return false;
        Map<String, Object> application = applications.get(0);
        String code = responseCode(data);
        String message = responseMessage(data);
        String configStatus = wechatAppletConfigStatus(data.get("result_list"));
        if (!providerSuccess(code) || "F".equalsIgnoreCase(configStatus) || "B".equalsIgnoreCase(configStatus)) {
            String reason = providerReason(code, message);
            jdbc.update("update merchant_onboarding_application set status='pending', wechat_config_status='failed', wechat_config_response_code=?, wechat_config_response_message=?, reject_reason=?, reviewed_at=null where id=?", code, message, "微信商户配置失败:" + reason, application.get("id"));
        } else {
            jdbc.update("update merchant_onboarding_application set wechat_config_status='approved', wechat_config_response_code=?, wechat_config_response_message=?, reject_reason=null where id=?", code, message, application.get("id"));
            continuePostOnboardingProvisioning(clean(application.get("applicationNo")));
        }
        return true;
    }
 
    @Transactional
    public boolean handleBusinessOpenCallback(Map<String, Object> callback) {
        Map<String, Object> data = callbackData(callback);
        String requestNo = clean(data.get("req_seq_id"));
        if (requestNo.isBlank()) return false;
        List<Map<String, Object>> applications = rows("select * from merchant_onboarding_application where business_open_request_no=? limit 1 for update", requestNo);
        if (applications.isEmpty()) return false;
        Map<String, Object> application = applications.get(0);
        String code = responseCode(data);
        String message = responseMessage(data);
        String auditStatus = clean(data.get("audit_status"));
        String businessStatus = wechatBusinessStatus(data);
        String applyNo = clean(data.get("apply_no"));
        boolean rejected = List.of("N", "F", "C").contains(auditStatus.toUpperCase()) || "F".equalsIgnoreCase(businessStatus) || !providerSuccess(code);
        if (rejected) {
            String reason = providerReason(code, message);
            jdbc.update("update merchant_onboarding_application set status='pending', business_open_status='failed', business_open_apply_no=coalesce(nullif(?,''),business_open_apply_no), business_open_response_code=?, business_open_response_message=?, reject_reason=?, reviewed_at=null where id=?", applyNo, code, message, "商户业务开通失败:" + reason, application.get("id"));
        } else if ("S".equalsIgnoreCase(businessStatus)) {
            jdbc.update("update merchant_onboarding_application set business_open_status='approved', business_open_apply_no=coalesce(nullif(?,''),business_open_apply_no), business_open_response_code=?, business_open_response_message=?, reject_reason=null where id=?", applyNo, code, message, application.get("id"));
            completePostOnboardingProvisioning(application.get("id"));
        } else {
            jdbc.update("update merchant_onboarding_application set business_open_status='processing', business_open_apply_no=coalesce(nullif(?,''),business_open_apply_no), business_open_response_code=?, business_open_response_message=? where id=?", applyNo, code, message, application.get("id"));
        }
        return true;
    }
 
    private void continuePostOnboardingProvisioning(String applicationNo) {
        List<Map<String, Object>> applications = rows("select * from merchant_onboarding_application where application_no=? limit 1 for update", applicationNo);
        if (applications.isEmpty()) return;
        Map<String, Object> application = applications.get(0);
        String huifuId = clean(application.get("providerHuifuId"));
        if (huifuId.isBlank() || "rejected".equals(clean(application.get("status")))) return;
 
        String wechatStatus = clean(application.get("wechatConfigStatus"));
        if (wechatStatus.isBlank() || "not_started".equals(wechatStatus) || "failed".equals(wechatStatus)) {
            String requestNo = clean(application.get("wechatConfigRequestNo"));
            if (requestNo.isBlank()) requestNo = provisioningRequestNo(applicationNo, "WC");
            jdbc.update("update merchant_onboarding_application set status='pending', wechat_config_status='processing', wechat_config_request_no=?, reject_reason=null, reviewed_at=null where id=?", requestNo, application.get("id"));
            try {
                HuifuMerchantWechatConfigGateway.Result result = merchantWechatConfigGateway.configure(requestNo, huifuId);
                if (!result.accepted()) {
                    jdbc.update("update merchant_onboarding_application set wechat_config_status='failed', wechat_config_response_code=?, wechat_config_response_message=?, reject_reason=? where id=?", result.code(), result.message(), "微信商户配置失败:" + providerReason(result.code(), result.message()), application.get("id"));
                    return;
                }
                jdbc.update("update merchant_onboarding_application set wechat_config_status='approved', wechat_config_response_code=?, wechat_config_response_message=? where id=?", result.code(), result.message(), application.get("id"));
            } catch (HuifuPaymentGatewayException exception) {
                String status = exception.isOutcomeUnknown() ? "processing" : "failed";
                jdbc.update("update merchant_onboarding_application set wechat_config_status=?, reject_reason=? where id=?", status, exception.isOutcomeUnknown() ? "微信商户配置结果待确认" : "微信商户配置请求失败", application.get("id"));
                return;
            } catch (BusinessException exception) {
                jdbc.update("update merchant_onboarding_application set wechat_config_status='failed', reject_reason=? where id=?", "微信商户配置失败:" + exception.getMessage(), application.get("id"));
                return;
            }
        } else if (!"approved".equals(wechatStatus)) {
            return;
        }
 
        application = rows("select * from merchant_onboarding_application where id=? limit 1", application.get("id")).get(0);
        String businessStatus = clean(application.get("businessOpenStatus"));
        if (businessStatus.isBlank() || "not_started".equals(businessStatus) || "failed".equals(businessStatus)) {
            String requestNo = clean(application.get("businessOpenRequestNo"));
            if (requestNo.isBlank()) requestNo = provisioningRequestNo(applicationNo, "BO");
            String shortName = clean(application.get("merchantShortName"));
            if (shortName.isBlank()) shortName = clean(application.get("merchantName"));
            if (shortName.isBlank()) shortName = clean(application.get("subjectName"));
            Long merchantId = ((Number) application.get("merchantId")).longValue();
            jdbc.update("update merchant_onboarding_application set status='pending', business_open_status='processing', business_open_request_no=?, reject_reason=null, reviewed_at=null where id=?", requestNo, application.get("id"));
            try {
                HuifuMerchantBusinessOpenGateway.Result result = merchantBusinessOpenGateway.open(requestNo, huifuId, shortName, mcc(merchantId));
                if (!result.accepted()) {
                    jdbc.update("update merchant_onboarding_application set business_open_status='failed', business_open_apply_no=nullif(?,''), business_open_response_code=?, business_open_response_message=?, reject_reason=? where id=?", clean(result.applyNo()), result.code(), result.message(), "商户业务开通失败:" + providerReason(result.code(), result.message()), application.get("id"));
                    return;
                }
                jdbc.update("update merchant_onboarding_application set business_open_status='processing', business_open_apply_no=nullif(?,''), business_open_response_code=?, business_open_response_message=? where id=?", clean(result.applyNo()), result.code(), result.message(), application.get("id"));
            } catch (HuifuPaymentGatewayException exception) {
                String status = exception.isOutcomeUnknown() ? "processing" : "failed";
                jdbc.update("update merchant_onboarding_application set business_open_status=?, reject_reason=? where id=?", status, exception.isOutcomeUnknown() ? "商户业务开通结果待确认" : "商户业务开通请求失败", application.get("id"));
            } catch (BusinessException exception) {
                jdbc.update("update merchant_onboarding_application set business_open_status='failed', reject_reason=? where id=?", "商户业务开通失败:" + exception.getMessage(), application.get("id"));
            }
        } else if ("approved".equals(businessStatus)) {
            completePostOnboardingProvisioning(application.get("id"));
        }
    }
 
    private void completePostOnboardingProvisioning(Object applicationId) {
        int completed = jdbc.update("update merchant_onboarding_application set status='approved', reject_reason=null, reviewed_at=now() where id=? and wechat_config_status='approved' and business_open_status='approved'", applicationId);
        if (completed > 0 && applicationId instanceof Number number) {
            // Approved sub-merchants need a confirmed settlement/encashment snapshot before they can collect money.
            paymentReadinessService.refreshQuietly(number.longValue());
        }
    }
 
    @SuppressWarnings("unchecked")
    private Map<String, Object> callbackData(Map<String, Object> callback) {
        Object nested = callback.get("data");
        if (nested instanceof Map<?, ?> value) return (Map<String, Object>) value;
        if (nested instanceof String value && !value.isBlank()) {
            try { return com.alibaba.fastjson.JSON.parseObject(value, Map.class); } catch (Exception ignored) { }
        }
        return callback;
    }
 
    @SuppressWarnings("unchecked")
    private List<Map<String, Object>> callbackList(Object raw) {
        if (raw instanceof List<?> values) return values.stream().filter(Map.class::isInstance).map(value -> (Map<String, Object>) value).toList();
        if (raw instanceof String value && !value.isBlank()) {
            try { return (List<Map<String, Object>>) (List<?>) com.alibaba.fastjson.JSON.parseArray(value, Map.class); } catch (Exception ignored) { }
        }
        return List.of();
    }
 
    private String wechatAppletConfigStatus(Object raw) {
        for (Map<String, Object> result : callbackList(raw)) if ("02".equals(clean(result.get("config_type")))) return clean(result.get("config_stat"));
        return "";
    }
 
    private String wechatBusinessStatus(Map<String, Object> data) {
        if (!"W".equalsIgnoreCase(clean(data.get("notify_type")))) return "";
        boolean found = false;
        for (Map<String, Object> result : callbackList(data.get("reg_result_list"))) {
            String status = clean(result.get("business_stat"));
            if ("F".equalsIgnoreCase(status)) return "F";
            if ("S".equalsIgnoreCase(status)) found = true;
        }
        return found ? "S" : "";
    }
 
    private String responseCode(Map<String, Object> data) { String code = clean(data.get("sub_resp_code")); return code.isBlank() ? clean(data.get("resp_code")) : code; }
    private String responseMessage(Map<String, Object> data) { String message = clean(data.get("audit_desc")); if (message.isBlank()) message = clean(data.get("sub_resp_desc")); return message.isBlank() ? clean(data.get("resp_desc")) : message; }
    private String provisioningRequestNo(String applicationNo, String suffix) { String value = applicationNo + suffix; return value.length() <= 32 ? value : value.substring(0, 32); }
    private void requireKey(String key) { if (key == null || key.isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少幂等键"); }
    private void store(MerchantPrincipal principal) { if (rows("select id from merchant_store where id = ? and merchant_id = ? and status = 1 and deleted = 0", principal.storeId(), principal.merchantId()).isEmpty()) throw new BusinessException(ErrorCode.FORBIDDEN, "当前门店不可操作"); }
    private Map<String, Object> onboarding(Map<String, Object> row) { return Map.of("id", String.valueOf(row.get("id")), "applicationNo", row.get("applicationNo"), "status", row.get("status"), "subjectType", clean(row.get("subjectType")), "reason", clean(row.get("rejectReason")), "submittedAt", String.valueOf(row.get("createdAt"))); }
    private Map<String, Object> bankCard(Map<String, Object> row) { return Map.of("id", String.valueOf(row.get("id")), "bankName", row.get("bankName"), "branchName", row.get("branchName"), "cardMasked", row.get("cardMasked"), "status", row.get("status"), "reason", clean(row.get("rejectReason")), "createdAt", String.valueOf(row.get("createdAt"))); }
    private Map<String, Object> withdrawal(Map<String, Object> row) { Map<String, Object> value = new LinkedHashMap<>(); value.put("id", String.valueOf(row.get("id"))); value.put("withdrawalNo", row.get("withdrawalNo")); value.put("amountCents", row.get("amountCents")); value.put("status", row.get("status")); value.put("reason", clean(row.get("failureReason"))); value.put("bankName", clean(row.get("bankName"))); value.put("cardMasked", clean(row.get("cardMasked"))); value.put("createdAt", String.valueOf(row.get("createdAt"))); value.put("reviewedAt", clean(row.get("reviewedAt"))); value.put("paidAt", clean(row.get("paidAt"))); return value; }
    private Map<String, Object> branch(String id, String branchName, String bankName) { return Map.of("id", id, "branchName", branchName, "bankName", bankName); }
    private Object[] appendPaging(List<Object> params, int size, int offset) { Object[] values = new Object[params.size() + 2]; for (int index = 0; index < params.size(); index++) values[index] = params.get(index); values[params.size()] = size; values[params.size() + 1] = offset; return values; }
    private List<Map<String, Object>> rows(String sql, Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql, args)); }
    private Long number(String value) { try { return Long.valueOf(value); } catch (Exception ignored) { throw new BusinessException(ErrorCode.BAD_REQUEST, "编号无效"); } }
    private String clean(Object value) { return value == null ? "" : String.valueOf(value).trim(); }
    private String cityKeyword(String value) { String text = clean(value); for (String suffix : List.of("自治州", "地区", "盟", "市", "州")) if (text.endsWith(suffix)) return text.substring(0, text.length() - suffix.length()); return text; }
    private String certificateValidityType(String value) { return "long_term".equals(value) ? "1" : "0"; }
    private String certificateBeginDate(String value) { return clean(value).replace("-", ""); }
    private String certificateEndDate(String value) { return certificateBeginDate(value); }
    private String provinceCode(String cityCode) { return cityCode != null && cityCode.matches("\\d{6}") ? cityCode.substring(0, 2) + "0000" : ""; }
    private void requireCertificatePeriod(String validityType, String beginDate, String endDate, String label) {
        if (blank(beginDate)) throw new BusinessException(ErrorCode.BAD_REQUEST, label + " begin date is required");
        if (!"long_term".equals(validityType) && blank(endDate)) throw new BusinessException(ErrorCode.BAD_REQUEST, label + " end date is required when it is not long-term");
        if (!blank(endDate) && clean(beginDate).compareTo(clean(endDate)) > 0) throw new BusinessException(ErrorCode.BAD_REQUEST, label + " end date must not precede its begin date");
    }
    private boolean blank(String value) { return value == null || value.isBlank(); }
    private String providerReason(String code, String message) { String value = clean(message); return value.isEmpty() ? "Huifu onboarding rejected: " + clean(code) : value; }
    private boolean providerSuccess(String code) { return "00000000".equals(code) || "00000100".equals(code) || "10000".equals(code) || "0".equals(code) || "SUCCESS".equalsIgnoreCase(code); }
    private String maskId(String id) { String value = clean(id); return value.length() < 8 ? "" : value.substring(0, 3) + "********" + value.substring(value.length() - 4); }
    private String sha256(String value) { try { byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); StringBuilder result = new StringBuilder(); for (byte valueByte : digest) result.append(String.format("%02x", valueByte)); return result.toString(); } catch (Exception exception) { throw new IllegalStateException("Unable to hash bank card", exception); } }
    private String entType(String companyType) {
        return switch (clean(companyType)) {
            case "政府机构" -> "1"; case "国营企业" -> "2"; case "私营企业" -> "3"; case "外资企业" -> "4";
            case "个体工商户" -> "5"; case "其它组织" -> "6"; case "事业单位" -> "7"; case "业主委员会" -> "9";
            default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "公司类型无效");
        };
    }
    private String mcc(Long merchantId) {
        List<Map<String, Object>> merchants = rows("select merchant_type_code, merchant_type from merchant where id=? and deleted=0 limit 1", merchantId);
        if (merchants.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "商家不存在");
        String code = clean(merchants.get(0).get("merchantTypeCode"));
        if (code.isBlank()) code = MerchantTypeCatalog.canonicalCode(clean(merchants.get(0).get("merchantType")));
        return MCC_BY_MERCHANT_TYPE.getOrDefault(code, "7399");
    }
    private String normalizeRegisteredCapital(String companyType, String value) {
        String text = clean(value); boolean required = List.of("国营企业", "私营企业", "外资企业", "其它组织", "事业单位").contains(clean(companyType));
        if (text.isBlank()) { if (required) throw new BusinessException(ErrorCode.BAD_REQUEST, "请输入注册资本"); return ""; }
        try { BigDecimal capital = new BigDecimal(text); if (capital.signum() < 0) throw new NumberFormatException(); return capital.setScale(2, RoundingMode.HALF_UP).toPlainString(); }
        catch (NumberFormatException exception) { throw new BusinessException(ErrorCode.BAD_REQUEST, "注册资本请输入数字,单位为万元"); }
    }
}