talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -2,7 +2,10 @@
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;
@@ -11,22 +14,103 @@
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;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
@Service
public class MerchantSettlementService {
    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(MerchantSettlementService.class);
    /** 商家档案给不出可识别经营类型时的兜底 MCC(「其它」)。 */
    private static final String FALLBACK_MCC = "7399";
    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 HuifuUserBusinessOpenGateway userBusinessOpenGateway;
    private final HuifuPaymentProperties huifuPaymentProperties;
    private final HuifuPictureUploadGateway pictureUploadGateway;
    private final MerchantPaymentReadinessService paymentReadinessService;
    @org.springframework.beans.factory.annotation.Autowired
    private MerchantOnboardingProgressService onboardingProgressService;
    @org.springframework.beans.factory.annotation.Autowired
    private MerchantRealnameService realnameService;
    @org.springframework.beans.factory.annotation.Autowired
    private com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway onboardingQueryGateway;
    public MerchantSettlementService(JdbcTemplate jdbc) { this.jdbc = jdbc; }
    public Map<String, Object> refreshOnboarding(MerchantPrincipal principal, boolean retry) {
        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());
        if(records.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND,"Onboarding application not found");
        return refreshProgress(((Number)records.get(0).get("id")).longValue(),retry);
    }
    /**
     * 刷新分阶段进度:**先推进已满足前置条件的开通步骤,再查询回报结果**。
     *
     * <p>分阶段模型下这条路径是商户与后台推进开通的唯一入口。渠道侧的微信商户配置与
     * 业务开通原本只由进件审核回调触发,而分阶段申请的回调走的是进度刷新——不在这里补上
     * 推进,新商户的微信开通就永远不会被发起。{@code continuePostOnboardingProvisioning}
     * 自身按状态判断是否还需要做,重复调用无害。
     */
    public Map<String, Object> refreshProgress(Long applicationId, boolean retry) {
        List<Map<String,Object>> records=rows("select * from merchant_onboarding_application where id=? and initial_bank_card_id is not null",applicationId);
        if(records.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND,"Onboarding application not found");
        String applicationNo=clean(records.get(0).get("applicationNo"));
        if (!applicationNo.isBlank()) continuePostOnboardingProvisioning(applicationNo);
        return onboardingProgressService.refresh(applicationId,retry);
    }
    /** 微信实名认证:商户主动推进的下一步。未决申请不会重复提交,见 MerchantRealnameService。 */
    public Map<String, Object> submitRealname(MerchantPrincipal principal, boolean retry) {
        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());
        if(records.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND,"Onboarding application not found");
        return realnameService.submit(((Number)records.get(0).get("id")).longValue(),retry);
    }
    public MerchantSettlementService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, HuifuIndividualOnboardingGateway individualOnboardingGateway, HuifuEnterpriseOnboardingGateway enterpriseOnboardingGateway, HuifuMerchantWechatConfigGateway merchantWechatConfigGateway, HuifuMerchantBusinessOpenGateway merchantBusinessOpenGateway, 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.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 = ? order by id desc limit 1", principal.merchantId(), principal.storeId());
        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));
    }
@@ -35,16 +119,150 @@
        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 status in ('pending','approved') order by id desc limit 1", principal.merchantId(), principal.storeId());
        if (!active.isEmpty()) return onboarding(active.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() && onboardingProgressService.supports(active.get(0))) {
            supplyMissingStorePhotos(active.get(0), request);
            return onboarding(active.get(0));
        }
        if (!active.isEmpty() && !retryable(active.get(0))) 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 (!enterprise && (request.subjectIdNumber() == null || !request.subjectIdNumber().trim().matches("(?:\\d{15}|\\d{17}[0-9X])"))) throw new BusinessException(ErrorCode.BAD_REQUEST, "请输入正确的身份证号");
        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, "请补全个人商户进件资料");
            // 汇付对个人(小微)商户的微信业务开通与微信实名认证都要门店照片(门头照 F22 + 内景照 F24)。
            // 放在提交时校验:这两张图是拿到微信收款能力的硬条件,缺了也能把进件提上去,
            // 但后面微信开通和实名一定被通道驳回,那时商户得把整份进件重走一遍。
            if (blank(request.storeHeaderUrl()) || blank(request.storeIndoorUrl())) 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, "所选开户行缺少汇付银行编码");
        // 上一次进件在通道侧已经明确失败(典型:本地开发没有公网 IP,收不到回调,商户配置报权限错),
        // 这条记录会永远停在 pending。此时必须让商户能用同一张卡重新提交:
        // 把旧进件标成 rejected,既解开下面「银行卡已有审核中/已通过进件」的占用,
        // 也让按 store 查 active 进件的逻辑不再把它当成进行中的申请。
        closeRetryableApplication(principal);
        String no = BusinessIdGenerator.next("MO");
        jdbc.update("insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name, credit_code, subject_id_no_masked, registered_region, registered_address, contact_name, contact_mobile, idempotency_key) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", principal.merchantId(), principal.storeId(), no, request.subjectType(), request.subjectName().trim(), clean(request.creditCode()), maskId(request.subjectIdNumber()), clean(request.registeredRegion()), clean(request.registeredAddress()), request.contactName().trim(), request.contactMobile().trim(), key.trim());
        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, legal_mobile, administrator_email, contact_name, contact_mobile, store_header_url, store_indoor_url, 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.legalMobile()), clean(request.administratorEmail()), request.contactName().trim(), request.contactMobile().trim(), clean(request.storeHeaderUrl()), clean(request.storeIndoorUrl()), 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));
    }
    /**
     * 存量个人进件缺门店照片时,允许商户在重新提交里补上。
     *
     * <p>门店照片是个人(小微)主体开通微信支付与发起微信实名的硬材料,而分阶段模型下重复提交
     * **不会新建进件**(也就不会在通道上多出一家商户),商户端因此没有别的补图入口——真实开发库里
     * 已经有拿到汇付商户号的个人进件,就是这个形态。
     *
     * <p>只补空着的那两项,已有值不覆盖;进件、银行卡与通道申请一律不动。
     */
    private void supplyMissingStorePhotos(Map<String, Object> application, MerchantOnboardingRequest request) {
        if (request == null || !"individual".equalsIgnoreCase(clean(application.get("subjectType")))) return;
        if (clean(application.get("storeHeaderUrl")).isBlank() && !blank(request.storeHeaderUrl())) {
            jdbc.update("update merchant_onboarding_application set store_header_url=? where id=?", request.storeHeaderUrl().trim(), application.get("id"));
        }
        if (clean(application.get("storeIndoorUrl")).isBlank() && !blank(request.storeIndoorUrl())) {
            jdbc.update("update merchant_onboarding_application set store_indoor_url=? where id=?", request.storeIndoorUrl().trim(), application.get("id"));
        }
    }
    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");
            // 店铺照片:个人(小微)商户进件的必填项,三种经营类型都要传,文件类型固定 F22。
            String storeHeaderPictureId = pictureUploadGateway.uploadUrl(request.storeHeaderUrl(), "F22");
            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(), "individual", request.companyType()), 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, storeHeaderPictureId));
            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) {
            // 只要通道给了商户号就可以往下走后续配置:商户号是「这笔进件已被受理」的标志。
            // token_no 是结算银行卡序列号,属于绑卡结果,不该当成后续配置的前置条件——
            // 用它做闸门会让银行卡审核慢的商户连微信支付配置和业务开通都一起卡住。
            boolean acceptedByProvider = !blank(huifuId);
            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=?", blank(tokenNo) ? "pending" : "approved", clean(tokenNo), clean(applyNo), clean(code), clean(message), blank(tokenNo) ? null : LocalDateTime.now(), bankCardId);
            if (acceptedByProvider) 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);
@@ -57,14 +275,20 @@
        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>> same = rows("select * from merchant_bank_card where merchant_id = ? and card_hash = ? limit 1", principal.merchantId(), hash);
        if (!same.isEmpty()) return bankCard(same.get(0));
        jdbc.update("insert into merchant_bank_card(merchant_id, store_id, bank_name, branch_name, city_code, card_tail, card_masked, card_hash, idempotency_key) values (?, ?, ?, ?, ?, ?, ?, ?, ?)", principal.merchantId(), principal.storeId(), request.bankName().trim(), request.branchName().trim(), request.cityCode().trim(), number.substring(number.length() - 4), "**** **** **** " + number.substring(number.length() - 4), hash, key.trim());
        return bankCard(rows("select * from merchant_bank_card where merchant_id = ? and card_hash = ?", principal.merchantId(), hash).get(0));
        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 keyword, int page, int size) {
    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", "中国建设银行攀枝花分行营业部", "中国建设银行"),
@@ -75,48 +299,631 @@
        return new PageResult<>(matched.subList(start, end), safePage, safeSize, matched.size());
    }
    @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());
    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
    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());
        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"));
        }
    }
    private void requireApproved(MerchantPrincipal principal) { if (!"approved".equals(onboardingStatus(principal).get("status"))) throw new BusinessException(ErrorCode.CONFLICT, "商户进件尚未审核通过"); }
    /**
     * 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 (onboardingProgressService.supports(application)) {
            if (!clean(data.get("huifu_id")).equals(clean(application.get("providerHuifuId")))) return false;
            refreshRecoveryAfterCommit(((Number)application.get("id")).longValue());
            return true;
        }
        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"));
            continueProvisioningAfterCommit(clean(application.get("applicationNo")));
        }
        return true;
    }
    @Transactional
    public boolean handleBusinessOpenCallback(Map<String, Object> callback) {
        Map<String, Object> data = callbackData(callback);
        Long recoveryId=onboardingProgressService.acceptDelayCallback(data);
        if (recoveryId != null) {
            refreshRecoveryAfterCommit(recoveryId);
            return true;
        }
        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"));
        // 这个异步通知里还带着微信实名状态与授权二维码(notify_type=W),原来两个字段一个都没读,
        // 等于把通道主动送来的实名进展丢掉了。有实名状态就落到 realname_status,并借一次进度刷新
        // 把二维码与阶段状态一并取回——二维码只在查询里给,通知里不解析它。
        String realnameState = clean(data.get("real_name_applyment_stat"));
        if (!realnameState.isBlank()) {
            jdbc.update("update merchant_onboarding_application set realname_status=? where id=?", realnameStatus(realnameState), application.get("id"));
            if (onboardingProgressService.supports(application)) refreshRecoveryAfterCommit(((Number) application.get("id")).longValue());
        }
        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"));
            completeProvisioningAfterCommit(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;
    }
    /**
     * 推进进件之后的配置:先微信小程序支付配置,再商户业务开通,两步都通过才算 approved。
     *
     * <p>由三种入口共用,所以是公开方法:审核结果回调、业务开通回调,以及主动轮询
     * (本地开发没有公网 IP 时汇付的异步通知推不进来,只能主动查)。
     *
     * <p>本方法自身不开事务:能力快照用 {@code REQUIRES_NEW} 写,而回调路径是先
     * {@code select ... for update} 锁行再走到这里,在事务内刷新快照会抢同一把行锁。
     */
    public 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;
        advanceBusinessOpen(applicationNo, application, huifuId);
        // 微信小程序配置必须排在业务开通**之后**,而且两步互不阻断:微信子商户号是业务开通创建的,
        // 先做配置会在通道侧报「根据渠道号或微信子商户号未查到入驻记录」(2026-09-16 新商户实测,
        // 当时配置失败还把业务开通整个跳过了,于是微信支付、人才端绑定、实名全部连坐)。
        advanceWechatConfig(applicationNo, fresh(application), huifuId);
        completePostOnboardingProvisioning(application.get("id"));
    }
    private Map<String, Object> fresh(Map<String, Object> application) {
        return rows("select * from merchant_onboarding_application where id=? limit 1", application.get("id")).get(0);
    }
    /**
     * 商户业务开通。排在微信小程序配置之前:这一步受理时通道就为三个线上场景创建了微信子商户号,
     * 微信小程序配置要挂在这个子商户号下面。
     */
    private void advanceBusinessOpen(String applicationNo, Map<String, Object> application, String huifuId) {
        String businessStatus = clean(application.get("businessOpenStatus"));
        // 通道侧已经开通微信业务时不再重新提交,否则会为同一商户开出第二份业务开通申请。
        // 同时把本地状态收口成 approved——**包括 processing**:业务开通回的是 90000000(审核中),
        // 没有回调时(本地开发收不到异步通知)没人把它推成 approved,而 approved 才是能力快照刷新
        // 与「整单完成」的入口,于是商户明明已经开通却一直停在审核中。
        if (!"approved".equals(businessStatus) && wechatBusinessEffective(huifuId)) {
            jdbc.update("update merchant_onboarding_application set business_open_status='approved', business_open_response_message=? where id=?", "通道已开通微信业务,按已开通收口", application.get("id"));
            businessStatus = "approved";
        }
        if (businessStatus.isBlank() || "not_started".equals(businessStatus) || "failed".equals(businessStatus)) {
            // 上一次是**明确失败**时必须换新号重试(通道按流水号去重);其余情况沿用已存的号,
            // 结果未知时换号会给同一商户开出第二份业务开通申请。
            String requestNo = "failed".equals(businessStatus) ? provisioningRequestNo(applicationNo, "BO", true) : clean(application.get("businessOpenRequestNo"));
            if (requestNo.isBlank()) requestNo = provisioningRequestNo(applicationNo, "BO", false);
            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();
            String subjectType = clean(application.get("subjectType"));
            boolean individual = "individual".equalsIgnoreCase(subjectType);
            // mcc 对「个体工商户、小微商户」是必填(官方原文),企业才可以用 fee_rule_id 代替。
            // 个体工商户走的是企业进件接口,所以不是 individual,但同样要带 mcc。
            // 注意:个人主体的 company_type 存的是进件时选的经营行业,不会等于「个体工商户」。
            boolean mccRequired = individual || "个体工商户".equals(clean(application.get("companyType")));
            // 个人(小微)商户的门店照片必须随业务开通一起交上去。缺图就直接收口成失败、
            // 不发这次请求:发出去也只会被通道驳回,还会在通道侧留下一条缺材料的申请。
            // 商户在进件页补上照片重新提交即可(见 supplyMissingStorePhotos),下次刷新会带材料重发。
            String storeHeaderFileId = "";
            String storeIndoorFileId = "";
            if (individual) {
                String storeHeaderUrl = clean(application.get("storeHeaderUrl"));
                String storeIndoorUrl = clean(application.get("storeIndoorUrl"));
                if (storeHeaderUrl.isBlank() || storeIndoorUrl.isBlank()) {
                    jdbc.update("update merchant_onboarding_application set business_open_status='failed', reject_reason=? where id=?", "商户业务开通失败:个人商户开通微信支付需要店铺照片与店内环境照,请在进件页补上这两张图后重新提交", application.get("id"));
                    return;
                }
                try {
                    storeHeaderFileId = pictureUploadGateway.uploadUrl(storeHeaderUrl, "F22");
                    storeIndoorFileId = pictureUploadGateway.uploadUrl(storeIndoorUrl, "F24");
                } catch (BusinessException | HuifuPaymentGatewayException exception) {
                    jdbc.update("update merchant_onboarding_application set business_open_status='failed', reject_reason=? where id=?", "门店照片上传失败:" + exception.getMessage(), application.get("id"));
                    return;
                }
            }
            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, subjectType, clean(application.get("companyType"))), individual, mccRequired, storeHeaderFileId, storeIndoorFileId);
                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"));
            }
        }
    }
    /**
     * 微信小程序支付配置(人才端 AppID 绑定)。排在业务开通之后,见 {@link #advanceBusinessOpen}。
     *
     * <p>这里不再因为本步失败就跳过后续步骤——两步各自记状态、各自可重试。原先配置一失败就
     * {@code return},业务开通永远轮不到,而通道侧的失败原因只会指向配置这一步,看不出是顺序问题。
     */
    private void advanceWechatConfig(String applicationNo, Map<String, Object> application, String huifuId) {
        String wechatStatus = clean(application.get("wechatConfigStatus"));
        // 本地状态可能与通道脱节(通道侧已经配好、本地还是 not_started)。微信商户配置是**添加**接口、
        // 成功后不支持修改删除(每种参数最多 5 组),配置已在通道生效时再提一次会留下重复配置。
        if (needsProvisioningStep(wechatStatus) && wechatConfigEffective(huifuId)) {
            jdbc.update("update merchant_onboarding_application set wechat_config_status='approved', wechat_config_response_message=? where id=?", "通道已有配置,按已配置收口", application.get("id"));
            return;
        }
        if (!needsProvisioningStep(wechatStatus)) return;
        // 业务开通被**受理**(90000000 审核中)不等于微信子商户号已经建好:微信入驻是异步的,受理那一刻
        // 通道侧查不到子商户号,此时发配置必然被驳回「根据渠道号或微信子商户号未查到入驻记录」,
        // 还会把 wechat_config_status 记成 failed,让人以为是配置本身有问题。
        // 所以闸门用通道证据而不是本地状态:`qry_wx_conf_list` 里真的出现线上场景才算开通。
        // 代价是要多刷一次(入驻完成后再来一次就会带上配置),换来的是不发注定被拒的请求。
        if (!wechatBusinessEffective(huifuId)) return;
        // 同理:上一次是明确失败时必须换新号,否则通道侧会把它当成重复请求。
        String requestNo = "failed".equals(wechatStatus) ? provisioningRequestNo(applicationNo, "WC", true) : clean(application.get("wechatConfigRequestNo"));
        if (requestNo.isBlank()) requestNo = provisioningRequestNo(applicationNo, "WC", false);
        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"));
        } catch (BusinessException exception) {
            jdbc.update("update merchant_onboarding_application set wechat_config_status='failed', reject_reason=? where id=?", "微信商户配置失败:" + exception.getMessage(), 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.
            refreshCapabilityAfterCommit(number.longValue());
        }
    }
    /**
     * 把「继续做后续配置」推迟到当前事务提交之后。
     *
     * <p>两个回调都是先 {@code select ... for update} 锁住进件记录,再走到后续配置;而后续配置会
     * **同步调用汇付的 HTTP 接口**(微信小程序配置、商户业务开通)。在事务里调外部接口意味着:
     * 拿着这条进件记录的行锁和一条数据库连接,去等一个不受控的网络响应。慢一点就把连接池占住,
     * 进而让**整张 merchant_onboarding_application 上的查询全部排队**——2026-09-15 后台全站
     * 不可用就是这个形态(一个空闲未提交事务持着元数据锁,DDL 无限等锁,10 条连接被借光)。
     *
     * <p>提交后再做还有第二个好处:回调只负责把通道结果落库,后续配置自己有重试入口
     * (业务开通回调、主动轮询),不会被回调事务的成败牵连。
     */
    private void continueProvisioningAfterCommit(String applicationNo) {
        runAfterCommit(() -> continuePostOnboardingProvisioning(applicationNo));
    }
    private void completeProvisioningAfterCommit(Object applicationId) {
        runAfterCommit(() -> completePostOnboardingProvisioning(applicationId));
    }
    /** 有活动事务就注册 afterCommit,否则立即执行(例如被非事务入口直接调用)。 */
    private void runAfterCommit(Runnable task) {
        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
            task.run();
            return;
        }
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                task.run();
            }
        });
    }
    /**
     * 进件审核通过后补一次收款能力快照。
     *
     * <p>必须在当前事务提交之后再刷新:能力快照用 {@code REQUIRES_NEW} 在独立事务里写,而微信商户配置
     * 回调和商户业务开通回调都是先 {@code select ... for update} 锁住这条进件记录再走到这里。在事务内
     * 刷新等于让新事务去抢本事务已经持有的同一行锁,会一直等到 {@code innodb_lock_wait_timeout} 然后以
     * {@code CannotAcquireLockException} 失败,快照永远补不上,商户虽然进件通过却始终无法收款。
     */
    private void refreshCapabilityAfterCommit(Long applicationId) {
        runAfterCommit(() -> paymentReadinessService.refreshQuietly(applicationId));
    }
    @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; }
    /**
     * 通道侧实名申请单状态映射到本地 realname_status。
     *
     * <p>这个异步通知只给申请单状态、不给授权状态,所以 {@code APPLYMENT_STATE_PASSED} 只能算
     * processing——授权是否完成要等查询到 {@code authorize_stat=1} 才能定,不能凭这一步就写 approved。
     */
    private static boolean needsProvisioningStep(String status) {
        return status.isBlank() || "not_started".equals(status) || "failed".equals(status);
    }
    /**
     * 微信小程序支付配置是否已经在通道生效:人才端 AppID 是否绑定在 {@code fee_type=01} 的配置项下。
     * 与分阶段进度用的是同一份证据。查询失败按「未生效」处理——宁可多提一次也不要漏配。
     */
    private boolean wechatConfigEffective(String huifuId) {
        try {
            Map<String, Object> config = onboardingQueryGateway.queryWechatConfig(huifuId);
            if (config == null || !"00000000".equals(clean(config.get("resp_code")))) return false;
            String appId = huifuPaymentProperties.getMiniProgramAppId().trim();
            for (Map<String, Object> entry : providerList(config.get("atu_config_bo_list"))) {
                if (!"01".equals(clean(entry.get("fee_type")))) continue;
                for (Map<String, Object> item : providerList(entry.get("wx_config_list"))) {
                    if ("1".equals(clean(item.get("config_type"))) && appId.equals(clean(item.get("config_value")))) return true;
                }
            }
            return false;
        } catch (RuntimeException exception) {
            log.warn("Huifu merchant WeChat config probe failed: huifuId={}", huifuId, exception);
            return false;
        }
    }
    /**
     * 微信业务是否已经在通道开通:商户详情的 {@code qry_wx_conf_list} 里出现线上三场景之一即视为已开通。
     * 业务开通是申请型接口,重复提交会给同一商户开出第二份申请,所以提交前必须先查。
     */
    private boolean wechatBusinessEffective(String huifuId) {
        try {
            Map<String, Object> detail = onboardingQueryGateway.queryCapabilities(huifuId);
            if (detail == null || !"00000000".equals(clean(detail.get("resp_code")))) return false;
            return providerList(detail.get("qry_wx_conf_list")).stream()
                    .anyMatch(item -> List.of("4", "5", "12").contains(clean(item.get("pay_scene"))));
        } catch (RuntimeException exception) {
            log.warn("Huifu merchant business open probe failed: huifuId={}", huifuId, exception);
            return false;
        }
    }
    /** 通道返回的列表字段可能是 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 static String realnameStatus(String applymentStat) {
        return switch (applymentStat) {
            case "APPLYMENT_STATE_REJECTED" -> "failed";
            case "APPLYMENT_STATE_NOAPPLY", "APPLYMENT_STATE_CANCELED" -> "not_started";
            default -> "processing";
        };
    }
    /**
     * 通道请求流水号。
     *
     * <p>{@code retry} 为真时在尾部追加随机后缀——**明确失败后的重试必须换新号**:通道按流水号
     * 去重,被驳回的申请沿用旧号会被当成重复请求。进件与实名走的是同一条规则
     * (见 {@code MerchantRealnameService.requestNo})。受理结果未知时不换号,避免为同一次提交
     * 开出第二份申请。
     *
     * <p>随机后缀必须保住:{@code applicationNo} 长度不保证,先按剩余宽度裁前缀再接后缀,
     * 否则超长截断会把随机位一起切掉,两次重试又变成同一个号。
     */
    private String provisioningRequestNo(String applicationNo, String suffix, boolean retry) {
        if (!retry) {
            String value = applicationNo + suffix;
            return value.length() <= 32 ? value : value.substring(0, 32);
        }
        String random = String.format("%05d", new java.util.Random().nextInt(100_000));
        String head = applicationNo + suffix + "R";
        int room = 32 - random.length();
        return (head.length() <= room ? head : head.substring(0, room)) + random;
    }
    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"), "reason", clean(row.get("rejectReason")), "submittedAt", String.valueOf(row.get("createdAt"))); }
    private void refreshRecoveryAfterCommit(Long id) {
        if (TransactionSynchronizationManager.isActualTransactionActive()) {
            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
                @Override public void afterCommit() { onboardingProgressService.refresh(id, false); }
            });
        } else onboardingProgressService.refresh(id, false);
    }
    private Map<String, Object> onboarding(Map<String, Object> row) {
        if (onboardingProgressService.supports(row)) return onboardingProgressService.view(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")), "retryable", retryable(row), "submittedAt", String.valueOf(row.get("createdAt")));
    }
    /**
     * 这笔进件是不是「通道侧已经明确失败、可以重新提交」。
     *
     * <p>进件成功后还有两步通道侧配置:微信小程序支付配置、商户业务开通。任何一步被通道
     * 明确拒绝(`failed`),后续流程就不会再自己往前走,而记录会一直停在 pending——
     * 本地开发收不到回调时尤其明显。不把它判成可重试,商户就永远卡在自己的 pending 里:
     * 进件页会跳转到"审核中",后端也会直接返回旧记录。
     */
    private boolean retryable(Map<String, Object> row) { return isRetryable(row); }
    /** 取值为静态形式,便于直接断言这条规则本身(见 MerchantOnboardingRetryTests)。 */
    static boolean isRetryable(Map<String, Object> row) {
        return "failed".equals(cleanStatus(row.get("wechatConfigStatus"))) || "failed".equals(cleanStatus(row.get("businessOpenStatus")));
    }
    private static String cleanStatus(Object value) { return value == null ? "" : String.valueOf(value).trim(); }
    /**
     * 商户重新提交时,把上一次「通道侧已明确失败」的进件连同它的银行卡一起收口。
     *
     * <p>只把进件标成 rejected 是不够的:银行卡校验是独立的一条,
     * `status='pending'` 的卡本身就足以拦住复用("该银行卡已有审核中的进件或已通过的申请")。
     * 而那张卡之所以停在 pending,正是这条失败的进件留下的,所以两者必须一起收口,
     * 否则商户连同一张卡都不能用——实际就踩到过这个坑。
     */
    void closeRetryableApplication(MerchantPrincipal principal) {
        List<Map<String, Object>> applications = 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 (applications.isEmpty()) return;
        Map<String, Object> application = applications.get(0);
        if (onboardingProgressService.supports(application)) {
            refreshRecoveryAfterCommit(((Number)application.get("id")).longValue());
            return;
        }
        if (!retryable(application)) return;
        if (onboardingProgressService.supports(application)) return;
        Object bankCardId = application.get("initialBankCardId");
        jdbc.update("update merchant_bank_card set status='rejected', reject_reason=?, reviewed_at=now() where id=? and status in ('pending','approved')", "通道侧配置失败,商户重新提交进件", bankCardId);
        jdbc.update("update merchant_onboarding_application set status='rejected', reject_reason=?, reviewed_at=now() where id=?", "通道侧配置失败,商户重新提交进件", application.get("id"));
    }
    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"))); 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, "公司类型无效");
        };
    }
    /**
     * 进件主体所属行业的 MCC。
     *
     * <p><b>行业以系统里的商家档案为准</b>(2026-09-16 业务口径:「行业改不了,只有我们系统动态地去匹配
     * 行业」)。商家档案上的经营类型是我们自己维护的,也是匹配微信行业类目与费率的依据,
     * 不该被进件表单上的一次选择盖掉;已进件商户也不提供补改行业的入口。
     *
     * <p>商户在个人进件表单第一步选的「行业类型」只在**档案给不出具体行业时**兜底
     * (档案未配、或配成「其它」未识别 → {@link #FALLBACK_MCC}),避免档案缺数据时一律落「其它」。
     *
     * <p>{@code mcc} 是进件的必填项;业务开通只在没有 {@code fee_rule_id} 时才用它兜底。
     */
    private String mcc(Long merchantId, String subjectType, String industryType) {
        String configured = mcc(merchantId);
        // 个体工商户走企业进件接口,表单上没有「行业类型」这一项,只有个人主体才有申报值。
        if (!"individual".equalsIgnoreCase(clean(subjectType))) return configured;
        return preferConfiguredMcc(configured, industryType);
    }
    /**
     * 系统档案优先、商户申报兜底。取值为静态形式,便于直接断言这条规则本身
     * (见 {@code MerchantOnboardingIndustryMccTests})。
     */
    static String preferConfiguredMcc(String configuredMcc, String declaredIndustry) {
        if (!FALLBACK_MCC.equals(cleanStatus(configuredMcc))) return configuredMcc == null ? "" : configuredMcc.trim();
        String declared = mccForIndustry(declaredIndustry);
        return declared.isBlank() ? FALLBACK_MCC : declared;
    }
    /**
     * 经营行业选中的 MCC。取值为静态形式,便于直接断言这条规则本身
     * (见 {@code MerchantOnboardingIndustryMccTests});返回空串表示这个行业识别不出来,交给商家档案兜底。
     */
    static String mccForIndustry(String industryType) {
        String industry = MerchantTypeCatalog.canonicalCode(cleanStatus(industryType));
        return "other".equals(industry) ? "" : MCC_BY_MERCHANT_TYPE.getOrDefault(industry, FALLBACK_MCC);
    }
    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, FALLBACK_MCC);
    }
    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, "注册资本请输入数字,单位为万元"); }
    }
}