yeg
2026-09-15 4fff3f4dd9ea9546b8974c4b6970144f691d60fa
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
package com.talentcard.server.business.merchant;
 
import java.sql.Connection;
import java.sql.ResultSet;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
 
/** Keeps merchant onboarding additive columns available when Flyway is disabled in the deployment. */
@Slf4j
@Component
public class MerchantOnboardingSchemaInitializer implements ApplicationRunner {
    /** DDL 等锁上限:拿不到就放弃这一列,绝不把整个服务拖住。 */
    private static final int DDL_LOCK_WAIT_TIMEOUT_SECONDS = 5;
    private final JdbcTemplate jdbc;
 
    public MerchantOnboardingSchemaInitializer(JdbcTemplate jdbc) { this.jdbc = jdbc; }
 
    @Override
    public void run(ApplicationArguments args) {
        try {
            Map<String, String> columns = new LinkedHashMap<>();
            columns.put("merchant_name", "varchar(128) default null");
            columns.put("merchant_short_name", "varchar(128) default null");
            columns.put("company_type", "varchar(64) default null");
            columns.put("business_license_url", "varchar(512) default null");
            columns.put("established_date", "char(10) default null");
            columns.put("registered_capital", "varchar(64) default null");
            columns.put("legal_id_front_url", "varchar(512) default null");
            columns.put("legal_id_back_url", "varchar(512) default null");
            columns.put("legal_certificate_address", "varchar(255) default null");
            columns.put("administrator_email", "varchar(128) default null");
            // 进件入口在插入申请时就写这些列(企业进件还要写入营业执照有效期);历史迁移漏建了它们,
            // 缺少时商户第一次提交进件就会以 SQL 语法错误失败,见 V137/V138。
            columns.put("registered_province_code", "varchar(6) default null");
            columns.put("registered_city_code", "varchar(6) default null");
            columns.put("registered_district_code", "varchar(6) default null");
            columns.put("license_validity_type", "varchar(8) default null");
            columns.put("license_begin_date", "char(8) default null");
            columns.put("license_end_date", "char(8) default null");
            columns.put("subject_cert_validity_type", "varchar(8) default null");
            columns.put("subject_cert_begin_date", "char(8) default null");
            columns.put("subject_cert_end_date", "char(8) default null");
            columns.put("initial_bank_card_id", "bigint default null");
            // 结算凭证图(对公=开户许可证、对私/个体工商户=银行卡卡号面)。进件时要上报通道,
            // 而进件在通道侧配置失败后需要能**重新提交**;不存这一列就没法复用商户填过的图,
            // 只能让商户把整张表重填一遍——实际就是为了这个原因反复重填过。
            columns.put("settlement_card_front_url", "varchar(512) default null");
            columns.put("provider_huifu_id", "varchar(32) default null");
            columns.put("provider_apply_no", "varchar(32) default null");
            columns.put("provider_response_code", "varchar(32) default null");
            columns.put("provider_response_message", "varchar(512) default null");
            columns.put("wechat_config_status", "varchar(16) not null default 'not_started'");
            columns.put("wechat_config_request_no", "varchar(32) default null");
            columns.put("wechat_config_response_code", "varchar(32) default null");
            columns.put("wechat_config_response_message", "varchar(512) default null");
            columns.put("business_open_status", "varchar(16) not null default 'not_started'");
            columns.put("business_open_request_no", "varchar(32) default null");
            columns.put("business_open_apply_no", "varchar(32) default null");
            columns.put("business_open_response_code", "varchar(32) default null");
            columns.put("business_open_response_message", "varchar(512) default null");
            columns.put("merchant_stat", "varchar(32) default null");
            columns.put("auto_settle_status", "char(1) default null");
            columns.put("cash_switch_status", "char(1) default null");
            columns.put("cash_type", "varchar(8) default null");
            columns.put("capability_response_code", "varchar(32) default null");
            columns.put("capability_response_message", "varchar(512) default null");
            columns.put("capability_verified_at", "datetime default null");
            for (Map.Entry<String, String> column : columns.entrySet()) {
                if (!hasColumn("merchant_onboarding_application", column.getKey())) addColumn("merchant_onboarding_application", column.getKey(), column.getValue());
            }
            Map<String, String> bankCardColumns = new LinkedHashMap<>();
            bankCardColumns.put("provider_token_no", "varchar(32) default null");
            bankCardColumns.put("provider_apply_no", "varchar(32) default null");
            bankCardColumns.put("provider_request_no", "varchar(32) default null");
            bankCardColumns.put("provider_response_code", "varchar(32) default null");
            bankCardColumns.put("provider_response_message", "varchar(512) default null");
            bankCardColumns.put("card_no_encrypted", "varchar(1024) default null");
            bankCardColumns.put("branch_code", "varchar(12) default null");
            for (Map.Entry<String, String> column : bankCardColumns.entrySet()) {
                if (!hasColumn("merchant_bank_card", column.getKey())) addColumn("merchant_bank_card", column.getKey(), column.getValue());
            }
        } catch (Exception exception) {
            log.warn("Merchant onboarding schema initialization failed", exception);
        }
    }
 
    /**
     * 加列,但**绝不无限期等锁**。
     *
     * <p>`ALTER TABLE` 需要排他元数据锁。只要表上还有一个没提交的事务(哪怕它已经读完、
     * {@code trx_query} 为空),DDL 就会一直 PENDING;而 MySQL 默认 `lock_wait_timeout` 是
     * 数万秒,于是 DDL 会一直挂着,**同时把该表上所有后续查询全部排队**,
     * 最终借光连接池、整个后台不可用。2026-09-15 就是这样瘫掉的:
     *
     * <pre>
     * conn 201  SHARED_READ(GRANTED)  空闲未提交事务,已持有 280 秒
     * conn 202  EXCLUSIVE(PENDING)    alter table ... add column settlement_card_front_url
     * 其余会话  SHARED_READ(PENDING)  全部排队
     * </pre>
     *
     * <p>所以这里显式把等待上限压到几秒:拿不到锁就放弃这一列,记一条日志,
     * 让应用照常启动(缺列只影响重试功能,不影响整个服务)。SQL 里用
     * {@code SET SESSION} 而非驱动参数,避免依赖 JDBC URL 是否带 allowMultiQueries。
     */
    private void addColumn(String tableName, String columnName, String definition) {
        try {
            jdbc.execute("set session lock_wait_timeout = " + DDL_LOCK_WAIT_TIMEOUT_SECONDS);
            jdbc.execute("alter table " + tableName + " add column " + columnName + " " + definition);
            log.info("Merchant onboarding schema: added {}.{}", tableName, columnName);
        } catch (Exception exception) {
            log.warn("Merchant onboarding schema: could not add {}.{} within {}s (table busy); it will be retried on the next restart. cause={}",
                    tableName, columnName, DDL_LOCK_WAIT_TIMEOUT_SECONDS, exception.getClass().getSimpleName());
        }
    }
 
    private boolean hasColumn(String tableName, String columnName) throws Exception {
        try (Connection connection = jdbc.getDataSource().getConnection(); ResultSet result = connection.getMetaData().getColumns(connection.getCatalog(), null, tableName, columnName)) {
            if (result.next()) return true;
        }
        try (Connection connection = jdbc.getDataSource().getConnection(); ResultSet result = connection.getMetaData().getColumns(connection.getCatalog(), null, tableName.toUpperCase(), columnName.toUpperCase())) {
            return result.next();
        }
    }
}