yeg
2026-09-15 dcc23e37a74502d027b7e1efeca57378c94273b1
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -31,6 +31,8 @@
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 {
@@ -47,22 +49,31 @@
    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;
    @org.springframework.beans.factory.annotation.Autowired
    private MerchantOnboardingProgressService onboardingProgressService;
    public MerchantSettlementService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, HuifuIndividualOnboardingGateway individualOnboardingGateway, HuifuEnterpriseOnboardingGateway enterpriseOnboardingGateway, HuifuMerchantWechatConfigGateway merchantWechatConfigGateway, HuifuMerchantBusinessOpenGateway merchantBusinessOpenGateway, HuifuMerchantWithdrawalService withdrawalService, HuifuUserBusinessOpenGateway userBusinessOpenGateway, HuifuPaymentProperties huifuPaymentProperties, HuifuPictureUploadGateway pictureUploadGateway) {
    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 onboardingProgressService.refresh(((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.withdrawalService = withdrawalService;
        this.userBusinessOpenGateway = userBusinessOpenGateway;
        this.huifuPaymentProperties = huifuPaymentProperties;
        this.pictureUploadGateway = pictureUploadGateway;
        this.paymentReadinessService = paymentReadinessService;
    }
    @Transactional(readOnly = true)
@@ -78,7 +89,7 @@
        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));
        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 (request.subjectIdNumber() == null || !request.subjectIdNumber().trim().matches("(?:\\d{15}|\\d{17}[0-9X])")) throw new BusinessException(ErrorCode.BAD_REQUEST, enterprise ? "请输入正确的法人身份证号" : "请输入正确的负责人身份证号");
@@ -100,6 +111,11 @@
        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");
        String certificateValidityType = certificateValidityType(request.subjectCertificateValidityType());
        String certificateBeginDate = certificateBeginDate(request.subjectCertificateBeginDate());
@@ -174,10 +190,13 @@
    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);
            // 只要通道给了商户号就可以往下走后续配置:商户号是「这笔进件已被受理」的标志。
            // 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=?", completed ? "approved" : "pending", clean(tokenNo), clean(applyNo), clean(code), clean(message), completed ? LocalDateTime.now() : null, bankCardId);
            if (completed) continuePostOnboardingProvisioning(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);
@@ -247,40 +266,6 @@
        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());
@@ -305,6 +290,10 @@
        }
    }
    /**
     * 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) {
@@ -318,15 +307,24 @@
        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, cards.get(0).get("id"));
        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), cards.get(0).get("id"));
        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, cards.get(0).get("id"));
        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) {
@@ -339,14 +337,15 @@
        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 status='pending', 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=?", 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=?", tokenNo, tokenNo, applyNo, code, message, bankCardId);
            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);
@@ -374,7 +373,7 @@
            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")));
            continueProvisioningAfterCommit(clean(application.get("applicationNo")));
        }
        return true;
    }
@@ -398,14 +397,23 @@
            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"));
            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;
    }
    private void continuePostOnboardingProvisioning(String applicationNo) {
    /**
     * 推进进件之后的配置:先微信小程序支付配置,再商户业务开通,两步都通过才算 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);
@@ -447,7 +455,7 @@
            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));
                HuifuMerchantBusinessOpenGateway.Result result = merchantBusinessOpenGateway.open(requestNo, huifuId, shortName, mcc(merchantId), "individual".equalsIgnoreCase(clean(application.get("subjectType"))));
                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;
@@ -465,7 +473,57 @@
    }
    private void completePostOnboardingProvisioning(Object applicationId) {
        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);
        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")
@@ -508,9 +566,43 @@
    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> 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")), "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 (!retryable(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"))); 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)); }