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 tables and 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; /** * 进件新增的表,与迁移里的建表语句逐字对齐(V144)。 * *

开发库(和任何 `spring.flyway.enabled=false` 的部署)不会自动补表:Flyway 不跑,而这个 * 初始化器原来只会加列。结果是 V144 建的两张表在开发库永远不存在,`GET * /api/merchant/onboarding/status` 的第一个请求就以 * `Table 'talent_card.merchant_onboarding_progress' doesn't exist` 500——测试库有 Flyway, * 看不出来。 */ private static final Map TABLES = new LinkedHashMap<>(); static { TABLES.put("merchant_onboarding_progress", """ create table if not exists merchant_onboarding_progress ( application_id bigint not null primary key, stages_json text not null, last_synced_at datetime null, sync_error varchar(512) not null default '', sync_owner varchar(36) null, sync_until datetime null, updated_at datetime not null default current_timestamp on update current_timestamp )"""); TABLES.put("merchant_onboarding_attempt", """ create table if not exists merchant_onboarding_attempt ( id bigint not null auto_increment primary key, application_id bigint not null, step varchar(24) not null, request_no varchar(32) not null, request_date char(8) not null, apply_no varchar(32) null, status varchar(24) not null, response_code varchar(32) null, response_message varchar(512) null, created_at datetime not null default current_timestamp, unique key uk_onboarding_attempt_request (request_no), key idx_onboarding_attempt_step (application_id, step, id) )"""); } private final JdbcTemplate jdbc; public MerchantOnboardingSchemaInitializer(JdbcTemplate jdbc) { this.jdbc = jdbc; } static Map tableDdl() { return Map.copyOf(TABLES); } @Override public void run(ApplicationArguments args) { try { for (Map.Entry table : TABLES.entrySet()) { if (!hasTable(table.getKey())) createTable(table.getKey(), table.getValue()); } Map 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"); // 商户端「立即核实」的冷却时间戳,见 V148。 columns.put("capability_sync_until", "datetime default null"); // 法人手机号:微信实名认证走法人时的必填项,实名接口的 name/mobile/id_card_number // 都必须是法人本人的,不能用联系人手机号顶替。见 V145 的说明。 columns.put("legal_mobile", "varchar(20) default null"); columns.put("realname_status", "varchar(16) not null default 'not_started'"); columns.put("realname_request_no", "varchar(32) default null"); columns.put("realname_apply_no", "varchar(32) default null"); columns.put("realname_response_code", "varchar(32) default null"); columns.put("realname_response_message", "varchar(512) default null"); // 门店照片:线下经营的门店材料是**一组三张**(门头照 F22、内景照 F24、收银台 F105), // 通道在进件这一步就按 F-code 逐张核对,企业与个人主体都要交。见 V146 与 V156 的说明。 columns.put("store_header_url", "varchar(512) default null"); columns.put("store_indoor_url", "varchar(512) default null"); columns.put("store_cashier_url", "varchar(512) default null"); // 经营详细地址:线下进件的 detail_addr 是通道必填项,与证件地址分开存。见 V155 的说明。 columns.put("business_address", "varchar(255) default null"); for (Map.Entry column : columns.entrySet()) { if (!hasColumn("merchant_onboarding_application", column.getKey())) addColumn("merchant_onboarding_application", column.getKey(), column.getValue()); } Map 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 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); } } /** * 加列,但**绝不无限期等锁**。 * *

`ALTER TABLE` 需要排他元数据锁。只要表上还有一个没提交的事务(哪怕它已经读完、 * {@code trx_query} 为空),DDL 就会一直 PENDING;而 MySQL 默认 `lock_wait_timeout` 是 * 数万秒,于是 DDL 会一直挂着,**同时把该表上所有后续查询全部排队**, * 最终借光连接池、整个后台不可用。2026-09-15 就是这样瘫掉的: * *

     * conn 201  SHARED_READ(GRANTED)  空闲未提交事务,已持有 280 秒
     * conn 202  EXCLUSIVE(PENDING)    alter table ... add column settlement_card_front_url
     * 其余会话  SHARED_READ(PENDING)  全部排队
     * 
* *

所以这里显式把等待上限压到几秒:拿不到锁就放弃这一列,记一条日志, * 让应用照常启动(缺列只影响重试功能,不影响整个服务)。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) { // 这里必须是 error 而不是 warn:进件的 INSERT/UPDATE 都显式列出列名,缺列不是"只影响重试功能", // 而是商户一提交进件就报 Unknown column。原先只记 warn、应用照常启动,于是症状变成 // "重启了还是报错、又看不出原因"(2026-09-16 在开发库的 legal_mobile 上实际发生过: // 同一轮循环里它后面的列都加成功了,只有它抢锁失败)。日志里直接给出补救语句。 log.error("Merchant onboarding schema: could not add {}.{} within {}s (table busy); the column is" + " referenced by the onboarding INSERT, so onboarding submissions will fail until it" + " exists. Retried on the next restart, or run manually: alter table {} add column {} {}." + " cause={}", tableName, columnName, DDL_LOCK_WAIT_TIMEOUT_SECONDS, tableName, columnName, definition, exception.getClass().getSimpleName()); } } /** 与 {@link #addColumn} 同样地失败即放弃:缺表只影响进件进度,不能让整个服务起不来。 */ private void createTable(String tableName, String ddl) { try { jdbc.execute("set session lock_wait_timeout = " + DDL_LOCK_WAIT_TIMEOUT_SECONDS); jdbc.execute(ddl); log.info("Merchant onboarding schema: created table {}", tableName); } catch (Exception exception) { log.warn("Merchant onboarding schema: could not create {} within {}s (table busy); it will be retried on the next restart. cause={}", tableName, DDL_LOCK_WAIT_TIMEOUT_SECONDS, exception.getClass().getSimpleName()); } } private boolean hasTable(String tableName) throws Exception { try (Connection connection = jdbc.getDataSource().getConnection(); ResultSet result = connection.getMetaData().getTables(connection.getCatalog(), null, tableName, new String[] { "TABLE" })) { if (result.next()) return true; } try (Connection connection = jdbc.getDataSource().getConnection(); ResultSet result = connection.getMetaData().getTables(connection.getCatalog(), null, tableName.toUpperCase(), new String[] { "TABLE" })) { return result.next(); } } 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(); } } }