yeg
2026-09-15 609bcde92512168ad67e05493a6dd8a72253379d
fix(merchant): 进件回调不再拿着数据库事务去等汇付 HTTP

微信商户配置回调与商户业务开通回调都是先 select ... for update 锁住进件记录,然后在事务里
同步调用汇付接口(v2.merchant.busi.config / v2.merchant.busi.open)。这等于拿着这条记录的
行锁和一条数据库连接去等一个不受控的网络响应:一旦汇付慢,连接被占住,而
merchant_onboarding_application 上的查询会连带排队。

2026-09-15 后台全站不可用正是这个形态:一个空闲未提交事务持着该表的元数据锁,
ALTER 无限等锁,该表所有查询排队,10 条连接的池子很快借光,健康检查都超时。

改动:
- 新增 runAfterCommit(Runnable):有活动事务就注册 afterCommit,否则立即执行。
- 两个回调里对后续配置的调用改为提交后再做(continueProvisioningAfterCommit /
completeProvisioningAfterCommit);回调自身只负责把通道结果落库。
后续配置有自己的重试入口(业务开通回调、主动轮询),不会被回调事务的成败牵连。
- refreshCapabilityAfterCommit 复用同一个 helper,去掉重复实现。
- application.yml 打开 Hikari leak-detection-threshold=10s:池只有 10 条连接,
超过 10 秒未归还的连接会连调用栈一起打进日志,下次能直接定位是谁在长事务里等外部调用。

回归:回调幂等、进件重试、支付结算等共 44 项通过。
2个文件已修改
56 ■■■■■ 已修改文件
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/application.yml 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -366,7 +366,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;
    }
@@ -390,7 +390,7 @@
            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"));
        }
@@ -474,6 +474,40 @@
    }
    /**
     * 把「继续做后续配置」推迟到当前事务提交之后。
     *
     * <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} 在独立事务里写,而微信商户配置
@@ -482,16 +516,7 @@
     * {@code CannotAcquireLockException} 失败,快照永远补不上,商户虽然进件通过却始终无法收款。
     */
    private void refreshCapabilityAfterCommit(Long applicationId) {
        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
            paymentReadinessService.refreshQuietly(applicationId);
            return;
        }
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                paymentReadinessService.refreshQuietly(applicationId);
            }
        });
        runAfterCommit(() -> paymentReadinessService.refreshQuietly(applicationId));
    }
    @SuppressWarnings("unchecked")
talent-card-server/src/main/resources/application.yml
@@ -14,6 +14,13 @@
    username: ${MYSQL_USERNAME:root}
    password: ${MYSQL_PASSWORD:123456}
    driver-class-name: com.mysql.cj.jdbc.Driver
    hikari:
      # 池只有 10 条连接,任何一条被长期占用都足以让整个后台排队到超时
      # (2026-09-15 的事故:一个空闲未提交事务持着元数据锁,ALTER 无限等锁,
      # 该表所有查询跟着排队,10 条连接很快被借光,健康检查都超时)。
      # 超过这个时间还没归还的连接,Hikari 会连调用栈一起打进日志——
      # 这是定位「谁在长事务里等外部 HTTP」最直接的手段。
      leak-detection-threshold: 10000
  data:
    redis:
      host: ${REDIS_HOST:localhost}