yeg
2026-09-15 621b51974f9baae62f81f523fa2ac8741dd304a5
fix(merchant): 开发库补齐进件进度表,初始化器增加建表路径

V144 为存量商户微信开通进度新建了 merchant_onboarding_progress 与 merchant_onboarding_attempt,
但开发库 spring.flyway.enabled=false,而 MerchantOnboardingSchemaInitializer 只有加列路径、
没有建表路径:这两张表在开发库永远不存在,GET /api/merchant/onboarding/status 的第一个请求
就以 `Table 'talent_card.merchant_onboarding_progress' doesn't exist` 500。测试库由 Flyway
建表,既有测试全绿,看不出这个缺口。

- 初始化器增加建表路径,建表语句逐字对齐 V144;等锁上限与"失败只记日志不影响启动"的策略
与加列保持一致,任何 Flyway 关闭的部署重启即自愈。
- 新增 MerchantOnboardingSchemaInitializerTests:断言迁移新建的进件表必须在初始化器登记、
且两侧列名一致。修复前为红(to contain ["merchant_onboarding_attempt",
"merchant_onboarding_progress"] but could not find)。
- 开发库按 docs/agents/testing.md 只补缺失 DDL、未删表:ApplyV144ToDevDatabase 直接读 V144
迁移文件加 if not exists 后执行,避免手抄 DDL 走样;ProbeOnboardingProgress 复现并验证
接口读取路径(修复前 FAIL、修复后 GREEN);VerifyInitializerDdl 在一次性 schema 里确认
初始化器携带的 DDL 可执行且与 V144 的列名与类型一致。

验证:全量后端测试 194 项,仅 docs/agents/testing.md 列的 3 项基线失败,无新增;接口由 500
变为 401(未登录)。
4个文件已添加
2个文件已修改
590 ■■■■■ 已修改文件
.scratch/merchant-wechat-onboarding/ApplyV144ToDevDatabase.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/merchant-wechat-onboarding/ProbeOnboardingProgress.java 106 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/merchant-wechat-onboarding/VerifyInitializerDdl.java 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/merchant-wechat-onboarding/evidence-2026-09-15.md 198 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializerTests.java 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/merchant-wechat-onboarding/ApplyV144ToDevDatabase.java
New file
@@ -0,0 +1,38 @@
import java.nio.file.*;
import java.sql.*;
import java.util.*;
/**
 * One-off backfill for the development database: applies V144's create-table statements with
 * `if not exists`, taken verbatim from the migration file so the dev schema cannot drift from it.
 *
 * <p>Needed only because a server started before {@code MerchantOnboardingSchemaInitializer} learned
 * to create tables keeps running with the old code path; after a restart the initializer does this
 * by itself, and on a Flyway-managed schema (the test database) Flyway has already done it.
 *
 * <p>Additive only: it never drops or alters anything, and it refuses to touch any table that
 * already exists.
 *
 * usage: java -cp <mysql-connector-j.jar>:. ApplyV144ToDevDatabase.java [jdbcUrl]
 */
public class ApplyV144ToDevDatabase {
  public static void main(String[] args) throws Exception {
    String url = args.length > 0
        ? args[0]
        : "jdbc:mysql://127.0.0.1:8134/talent_card?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false";
    String migration = Path.of("../../talent-card-server/src/main/resources/db/migration/V144__existing_merchant_onboarding_progress.sql").toAbsolutePath().normalize().toString();
    String[] statements = Files.readString(Path.of(migration))
        .replaceAll("(?i)create table (?!if not exists)", "create table if not exists ")
        .split(";\\s*\\n");
    try (Connection connection = DriverManager.getConnection(url, "root", "123456")) {
      for (String statement : statements) {
        if (statement.isBlank()) continue;
        try (Statement executor = connection.createStatement()) {
          executor.execute(statement);
          System.out.println("applied: " + statement.strip().split("\\n")[0]);
        }
      }
    }
  }
}
.scratch/merchant-wechat-onboarding/ProbeOnboardingProgress.java
New file
@@ -0,0 +1,106 @@
import java.nio.file.*;
import java.sql.*;
import java.util.*;
import java.util.regex.*;
/**
 * Reproduces GET /api/merchant/onboarding/status against the development database, down to the
 * statements the service actually issues. Read-only.
 *
 * <p>Also compares the development schema with the additive columns
 * {@link com.talentcard.server.business.merchant.MerchantOnboardingSchemaInitializer} is supposed to
 * add at startup: that initializer deliberately swallows "could not take the lock" failures, so a
 * column can be silently missing without any request having failed yet.
 *
 * usage: java -cp <mysql-connector-j.jar>:. ProbeOnboardingProgress.java [jdbcUrl] [initializerSource]
 */
public class ProbeOnboardingProgress {
  public static void main(String[] args) throws Exception {
    String url = args.length > 0
        ? args[0]
        : "jdbc:mysql://127.0.0.1:8134/talent_card?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false";
    String source = args.length > 1
        ? args[1]
        : "../../talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java";
    String database = url.split("\\?")[0].substring(url.split("\\?")[0].lastIndexOf('/') + 1);
    boolean red = false;
    try (Connection connection = DriverManager.getConnection(url, "root", "123456")) {
      connection.setReadOnly(true);
      for (String table : new String[] { "merchant_onboarding_progress", "merchant_onboarding_attempt" }) {
        boolean exists = exists(connection, database, table);
        System.out.println("table " + table + " exists = " + exists);
      }
      System.out.println();
      // The two statements MerchantOnboardingProgressService issues on the status path.
      red |= run(connection, "select * from merchant_onboarding_progress where application_id=?", "view()", 1L);
      red |= run(connection,
          "select * from merchant_onboarding_attempt where application_id=? and step='delay' order by id desc limit 1", "delayAttempt()", 1L);
      // The statements MerchantSettlementService issues before it reaches the progress view.
      red |= run(connection,
          "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", "onboardingStatus()", -1L, -1L);
      red |= run(connection, "select id from merchant_store where id=? and merchant_id=? and status=1 and deleted=0", "store()", -1L, -1L);
      System.out.println();
      for (String table : new String[] { "merchant_onboarding_application", "merchant_bank_card" }) {
        List<String> declared = declaredColumns(source, table);
        List<String> missing = new ArrayList<>();
        for (String column : declared) if (!hasColumn(connection, database, table, column)) missing.add(column);
        System.out.println(table + ": initializer declares " + declared.size() + " columns, missing in this database = "
            + (missing.isEmpty() ? "none" : missing));
      }
    }
    System.out.println();
    System.out.println(red ? "RED: the endpoint's read path does not run against this database" : "GREEN: every statement on the endpoint's read path ran");
    System.exit(red ? 1 : 0);
  }
  private static boolean run(Connection connection, String sql, String label, Object... arguments) {
    try (PreparedStatement statement = connection.prepareStatement(sql)) {
      for (int index = 0; index < arguments.length; index++) statement.setObject(index + 1, arguments[index]);
      try (ResultSet rows = statement.executeQuery()) {
        int count = 0;
        while (rows.next()) count++;
        System.out.println("ok   " + label + " -> rows=" + count);
        return false;
      }
    } catch (SQLException exception) {
      System.out.println("FAIL " + label + " -> " + exception.getMessage());
      return true;
    }
  }
  /** Column names the initializer adds to one table, in declaration order. */
  private static List<String> declaredColumns(String source, String table) throws Exception {
    String map = table.equals("merchant_bank_card") ? "bankCardColumns" : "columns";
    Matcher matcher = Pattern.compile("\\b" + map + "\\.put\\(\"([a-z_]+)\"").matcher(Files.readString(Path.of(source)));
    List<String> names = new ArrayList<>();
    while (matcher.find()) names.add(matcher.group(1));
    return names;
  }
  private static boolean exists(Connection connection, String database, String table) throws SQLException {
    try (PreparedStatement statement = connection.prepareStatement(
        "select count(*) from information_schema.tables where table_schema=? and table_name=?")) {
      statement.setString(1, database);
      statement.setString(2, table);
      try (ResultSet rows = statement.executeQuery()) {
        return rows.next() && rows.getInt(1) > 0;
      }
    }
  }
  private static boolean hasColumn(Connection connection, String database, String table, String column) throws SQLException {
    try (PreparedStatement statement = connection.prepareStatement(
        "select count(*) from information_schema.columns where table_schema=? and table_name=? and column_name=?")) {
      statement.setString(1, database);
      statement.setString(2, table);
      statement.setString(3, column);
      try (ResultSet rows = statement.executeQuery()) {
        return rows.next() && rows.getInt(1) > 0;
      }
    }
  }
}
.scratch/merchant-wechat-onboarding/VerifyInitializerDdl.java
New file
@@ -0,0 +1,90 @@
import java.nio.file.*;
import java.sql.*;
import java.util.*;
import java.util.regex.*;
/**
 * Verifies that the create-table statements {@code MerchantOnboardingSchemaInitializer} carries are
 * executable on MySQL and produce exactly the columns V144 produces.
 *
 * <p>Worth checking separately: the initializer swallows DDL failures on purpose (a busy table must not
 * stop the service), so a typo there would keep the tables missing after a restart — silently, with
 * the very same symptom this session started from.
 *
 * <p>Runs in its own throwaway schema and drops only that schema, which it created itself; the
 * development and test databases are never written to.
 *
 * usage: java -cp <mysql-connector-j.jar>:. VerifyInitializerDdl.java [serverJdbcUrl] [initializerSource]
 */
public class VerifyInitializerDdl {
  private static final String PROBE_SCHEMA = "talent_card_schema_probe";
  public static void main(String[] args) throws Exception {
    String serverUrl = args.length > 0
        ? args[0]
        : "jdbc:mysql://127.0.0.1:8134/talent_card?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false";
    String source = Path.of(args.length > 1
        ? args[1]
        : "../../talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java").toAbsolutePath().normalize().toString();
    Map<String, String> ddl = textBlocksWithCreateTable(Files.readString(Path.of(source)));
    System.out.println("initializer carries " + ddl.size() + " create-table statements: " + ddl.keySet());
    String probeUrl = serverUrl.replaceFirst("/[^/?]+\\?", "/" + PROBE_SCHEMA + "?");
    try (Connection connection = DriverManager.getConnection(serverUrl, "root", "123456")) {
      try (Statement statement = connection.createStatement()) {
        statement.executeUpdate("create database if not exists " + PROBE_SCHEMA + " default charset utf8mb4");
      }
    }
    boolean mismatch = false;
    try (Connection probe = DriverManager.getConnection(probeUrl, "root", "123456");
         Connection development = DriverManager.getConnection(serverUrl, "root", "123456")) {
      for (Map.Entry<String, String> table : ddl.entrySet()) {
        try (Statement statement = probe.createStatement()) {
          statement.execute(table.getValue());
        }
        Set<String> fromInitializer = columns(probe, PROBE_SCHEMA, table.getKey());
        Set<String> fromMigration = columns(development, "talent_card", table.getKey());
        boolean same = fromInitializer.equals(fromMigration);
        mismatch |= !same;
        System.out.println((same ? "ok   " : "DIFF ") + table.getKey() + " -> initializer=" + fromInitializer);
        if (!same) System.out.println("     migration=" + fromMigration);
      }
    } finally {
      try (Connection connection = DriverManager.getConnection(serverUrl, "root", "123456");
           Statement statement = connection.createStatement()) {
        statement.executeUpdate("drop database " + PROBE_SCHEMA);
      }
      System.out.println("dropped the probe schema " + PROBE_SCHEMA + "; talent_card and talent_card_test untouched");
    }
    System.out.println(mismatch ? "MISMATCH: the initializer's DDL does not match the migration" : "MATCH: the initializer's DDL is executable and identical to the migration's");
    System.exit(mismatch ? 1 : 0);
  }
  /** name:type pairs, so a wrong type or length shows up, not just a wrong column name. */
  private static Set<String> columns(Connection connection, String schema, String table) throws SQLException {
    try (PreparedStatement statement = connection.prepareStatement(
        "select column_name, column_type from information_schema.columns where table_schema=? and table_name=? order by ordinal_position")) {
      statement.setString(1, schema);
      statement.setString(2, table);
      Set<String> columns = new LinkedHashSet<>();
      try (ResultSet rows = statement.executeQuery()) {
        while (rows.next()) columns.add(rows.getString(1) + ":" + rows.getString(2));
      }
      return columns;
    }
  }
  private static Map<String, String> textBlocksWithCreateTable(String source) {
    Map<String, String> statements = new LinkedHashMap<>();
    Matcher blocks = Pattern.compile("\"\"\"\\r?\\n(.*?)\"\"\"", Pattern.DOTALL).matcher(source);
    while (blocks.find()) {
      String block = blocks.group(1);
      Matcher name = Pattern.compile("(?i)create table if not exists ([a-z_]+)").matcher(block);
      if (name.find()) statements.put(name.group(1), block);
    }
    if (statements.isEmpty()) throw new IllegalStateException("no create-table text blocks found in " + source);
    return statements;
  }
}
.scratch/merchant-wechat-onboarding/evidence-2026-09-15.md
@@ -1,8 +1,13 @@
# Ticket02 实施中的只读核验
# Ticket02 汇付只读核验与真实证据
核验时间:2026-09-15(Asia/Hong_Kong)。本记录不表示 Ticket02 验收完成,03 仍阻塞。
本记录不表示 Ticket02 验收完成,03 仍阻塞。文件按时间追加,**以最后一节「第二轮」为准**。
## 原商户与申请
**当前状态摘要:** 第一轮的 20002 数据权限阻塞在凭据轮换(`sys_id` 换为 `6666000220591054`)后已解除;
第二轮已用真实接口取得目标商户与参考商户的完整状态,并把阶段二的四个合同未知项逐项裁定。
目标商户现状:**基础进件已通过**(`apply_status=Y`),**微信能力完全未开**,**延迟入账未开**(`delay_flag=N`),
**D1 结算与起结/留存已符合**。唯一仍明确的资金缺口是延迟入账;补开仍需选定 CID。
## 第一轮:原商户与申请(白天,当时无查询权限)
- 目标:6666000241335656,成都喜望科技有限公司;控台状态“正常”。
- 本地申请 ID 449,申请号 MO202609141804572521,汇付申请单 2026091429610274。
@@ -34,9 +39,10 @@
- [申请单状态查询](https://paas.huifu.com/docs/api/doc/#/shgl/shjj/api_shjj_sqdztcx):实际端点 v2/merchant/basicdata/status/query;apply_no 和 huifu_id 必填。apply_status 为 Y/P/N/F,各业务开通状态另外返回。
- [商户详情查询](https://paas.huifu.com/docs/api/doc/#/shgl/shjj/api_shjj_shxxxxcx_kyc):mer_stat=NORMAL;delay_flag=Y、forced_delay_flag=N;qry_settle_config_list 包含 settle_status=1、settle_cycle=D1、settle_pattern=P0、min_amt/remained_amt、原 token_no。起结/留存无值语义为不设置。
## 尚待完成
## 尚待完成(第一轮结论,已部分被第二轮取代)
真实接口访问权限、微信及 AppID/实名证据、补开与局部重试、两端页面和权限验收、并发/回调测试。当前完成五个模拟集成场景:基础查询权限错误保护、基础通过但能力不全、能力查询错误保护、返回缺少字段保留证据、旧重提入口保留目标商户申请。不能将本票标完成。
真实接口访问权限已由第二轮解除;微信及 AppID/实名真实证据、真实补开仍未完成。第一轮当时完成五个模拟集成场景:
基础查询权限错误保护、基础通过但能力不全、能力查询错误保护、返回缺少字段保留证据、旧重提入口保留目标商户申请。
全量后端测试 179 项:176 通过、1 断言失败、2 错误,三项失败均为 docs/agents/testing.md 列明的基线问题;PaymentSettlementTests、PaymentFundConfirmationTests 在本次全量中通过。另发现既有 HuifuSignatureServiceTests 会输出运行配置明文私钥,已改为使用其现成 keySummary 摘要函数。
@@ -45,3 +51,185 @@
Standards:审查发现的能力查询失败遗漏和缺字段覆盖有效状态已补测试并修正。
Spec:仍为未完成的实现检查点,补开、局部重试、两端入口、实名/AppID/资金完整证据、回调一致性与真实验收均未完成。旧提交关闭原商户/银行卡的风险已针对指定商户阻断;原任务范围不变,03 仍阻塞。
## 缺陷:开发库缺 V144 的两张表,接口首请求 500
现象:`GET http://127.0.0.1:8080/api/merchant/onboarding/status` 返回 500,服务端
`BadSqlGrammarException ... Table 'talent_card.merchant_onboarding_progress' doesn't exist`
(MerchantOnboardingProgressService.rows:261 ← view:214 ← MerchantSettlementService.onboarding:589)。
根因:V144 新建的 `merchant_onboarding_progress`、`merchant_onboarding_attempt` 在开发库从未建立。
开发库 `spring.flyway.enabled=false`,而负责补结构的 `MerchantOnboardingSchemaInitializer` 只会加列、
没有建表路径;测试库由 Flyway 建表,所以测试全绿。与"缺列"同类的坑在 V137/V138/V143 已经踩过一次,
这次轮到表。
只读证据(`.scratch/merchant-wechat-onboarding/ProbeOnboardingProgress.java`,跑在 127.0.0.1:8134/talent_card):
- 修复前两张表 `exists = false`;`view()` 与 `delayAttempt()` 两条语句 FAIL,报错与上面逐字相同。
- onboarding 路径用到的 `merchant_onboarding_application`(41 列)、`merchant_bank_card`(7 列) 已经齐全,
  缺的确实只是这两张表。
- 开发库 `flyway_schema_history` 为 0 行,确认它不归 Flyway 管。
处置:
- 开发库按 `docs/agents/testing.md` 的约定只补缺失 DDL、未删表:`ApplyV144ToDevDatabase.java` 直接读
  V144 迁移文件、加 `if not exists` 后执行,避免手抄 DDL 走样。
- 代码修在 `MerchantOnboardingSchemaInitializer`:新增建表路径,逐字对齐 V144,等锁上限与加列一致、
  失败只记日志不影响启动,使任何 Flyway 关闭的部署重启即自愈。
- 回归保护 `MerchantOnboardingSchemaInitializerTests`:断言"迁移新建的进件表必须在初始化器登记"与
  "两侧列名一致"。修复前为红(`to contain ["merchant_onboarding_attempt",
  "merchant_onboarding_progress"] but could not find`)。
验证:探针由红转绿;`MerchantExistingOnboardingTests` 17 项通过;接口由 500 变为 401(未登录),
库层面不再报错。`VerifyInitializerDdl.java` 在一次性 schema 里执行初始化器携带的 DDL,得到的列名与类型
与 V144 建的开发库表逐字相同(该 schema 用完即删,未动 talent_card / talent_card_test)。
---
## 第二轮:凭据轮换后的真实接口核验
第一轮的 20002 数据权限阻塞**已解除**:改用 `sys_id=6666000220591054`(目标商户的直属上级渠道)与配套公私钥后,
查询目标商户与参考商户均返回真实业务数据。第一轮那张 20002/HTTP 922 对照表保留在文末,仅作历史记录。
以下区分「官方合同」「真实接口返回」与「仍未确定」,不把文档示例或参考商户配置当成目标商户已开通。
### 可复现方式
只读探针 `QueryCapabilities.java`,复用模块已有 classpath:
```
javac -encoding UTF-8 -cp "$(cat talent-card-server/target/cp.txt)" -d talent-card-server/target/probe-classes \
    .scratch/merchant-wechat-onboarding/QueryCapabilities.java
java -cp "talent-card-server/target/probe-classes;$(cat talent-card-server/target/cp.txt)" \
    QueryCapabilities <huifu_id> [apply_no]
```
探针内只放行六个只读端点(详情、微信配置、费率、多费率、实名、申请单状态),不含任何写入接口;
身份取 `application.yml` 的 `app.payment.huifu`,商品号 `KAZX`。本轮未产生新申请单、未重复提交、未修改任何商户。
### 目标商户 6666000241335656 的真实状态
| 查询 | 真实返回 | 结论 |
| --- | --- | --- |
| 申请单状态(`apply_no=2026091429610274`) | `apply_status=Y`、`reg_status=S`、`bind_card_status=S`、`cash_status=S`、`settle_status=S`、`surrogate_status=I`,**无 `wx_conf_result_list`** | 基础进件已审核通过;原申请本来就没带微信业务,不是微信开通失败 |
| 商户详情 | `mer_stat=NORMAL`、`upper_huifu_id=6666000220591054`、`parent_huifu_id=""`、`head_office_flag=LX`、`mer_level=LX` | 层级是「渠道商 → 普通商户」,直属上级即当前调用身份 |
| 商户详情 | `mcc=5732`(电子设备商店),而 `short_name`/`receipt_name` 已是「喜望科技软件公司」,`reg_name=成都喜望科技有限公司` | 商户记录行业与主营不一致,见「仍未确定」 |
| 商户详情 | `delay_flag=N`、`forced_delay_flag=N` | **延迟入账未开通**;强制延迟已为 N,符合要求 |
| 商户详情 | `qry_settle_config_list`:`settle_status=1`、`settle_cycle=D1`、`settle_pattern=P0`、`settle_type=0`、`min_amt=0.00`、`remained_amt=0.00`、`token_no=10069981143` | **D1 自动批次结算、起结 0、留存 0 已生效**,无需再补 |
| 商户详情 | `qry_cash_card_info_list`:卡号尾号 7957、`token_no=10069981143`,与 `settle_config.token_no` 一致 | 结算卡仍是原申请那张(本地 `initial_bank_card_id=477`,尾号 7957) |
| 商户详情 | **无 `qry_wx_conf_list`** | 商户详情里没有任何微信配置 |
| 微信配置查询 | 只有 `resp_code=00000000/成功`,**无 `atu_config_bo_list`** | 人才端 AppID **未绑定**(合同明确:只有 00000000 而无配置列表不能判定已绑定) |
| 微信费率查询 | `resp_code=00000003`、`resp_desc=未查到商户费率配置信息` | 没有任何微信费率配置 |
| 多费率查询 | `resp_code=00000000`、`conf_list=null` | 未启用多费率 |
| 微信实名状态查询(`pay_scene=01`) | `resp_code=00000001`、`resp_desc=AT入驻信息池信息不存在` | 尚未入驻微信(该接口前提是已入驻),实名申请信息不存在 |
### 参考商户 6666000226048687(渡口云联)的真实配置
同一直属上级 `6666000220591054`,`resp_code=00000000`,可作为阶段二「公共配置」的对照模板。
微信配置查询:
```json
[{"bank_channel_no":"00016904","fee_type":"01",
  "wx_applet_app_id":"wxcfee477acb54d179","micro_sub_appid":"","wx_woa_app_id":"","wx_woa_path":"",
  "wx_config_list":[{"config_type":"1","config_value":"wxcfee477acb54d179"},
                    {"config_type":"1","config_value":"wx550e520ad19458ea"}]}]
```
只有一条配置信息,`fee_type=01`、`bank_channel_no=00016904`,**两个小程序 AppID 里第一个就是本项目的人才端
`wxcfee477acb54d179`**;无 `control_flag`,即未被管控。
微信费率查询:
```json
"wx_fee_rate_list":[
 {"pay_scene":"1","fee_min_amt":"0.00","pay_type":"C03","fee_rate":"0.30"},
 {"pay_scene":"2","fee_min_amt":"0.00","pay_type":"C04","fee_rate":"0.30"},
 {"pay_scene":"3","fee_min_amt":"0.00","pay_type":"C05","fee_rate":"0.30"},
 {"pay_scene":"4","fee_min_amt":"0.00","pay_type":"C06","fee_rate":"0.70"},
 {"pay_scene":"5","fee_min_amt":"0.00","pay_type":"C07","fee_rate":"0.70"},
 {"pay_scene":"12","fee_min_amt":"0.00","pay_type":"C23","fee_rate":"0.70"}]
```
商户详情关键字段:
- `qry_wx_conf_list` 六个场景齐全。线上三场景(4/5/12)均为 `fee_charge_type="1"`、`fee_min_amt="0.00"`、
  `fee_rate="0.70"`、`out_fee_flag="2"`,各自带 `pay_channel_id_list:["00016904"]`,以及
  `wx_mer_infos:[{bank_mer_code:"912740777",nucc_bank_mer_code:"912740777",is_default:"1",
                 pay_channel_id:"00016904",sub_mer_id:"W15737921333056428494"}]`。
- `mer_config_info.wx_conf_list` 为 `[{feeRate:"0.70",openFlag:"Y",payScene:"4"},{…"5"},{…"12"}]`;
  其 `settle_config_list` 为 `[{busType:"D1",feeFixAmt:"0.00",feeRate:"0.00",openFlag:"Y"}]`。
- `mcc=7372`、`delay_flag=Y`、`forced_delay_flag=N`、`out_fee_flag=2`、`mer_stat=NORMAL`。
- 微信实名状态查询:`resp_code=00000000`、`applyment_stat=APPLYMENT_STATE_FREEZED`、`authorize_stat=1`、
  `smid=912740777`、`pay_channel_id=00016904`、`pay_scene=01`、`reject_param=merchant_name`,
  `reject_reason=该主体已完成过商家注册,无需重复注册,请撤销该申请单并联系管理员进行商户号授权`,
  并返回真实 Base64 二维码(JPEG,此处不转载)。
### 阶段二四项合同未知项的裁定
| # | 原未知项 | 裁定 | 依据 |
| --- | --- | --- | --- |
| 1 | 软件开发/技术服务企业的线上 CID 与场景 12 | **cid=756**;场景 12 在该表无独立行,随 4/5 共用线上规则 | 见下「CID 裁定」 |
| 2 | 最低收费能否为 0 | 可以;**入参省略 `fee_min_amt`,以读回 `0.00` 为验收** | 合同该参数可选、写明「大于 0」;参考商户线上三场景真实读回 `"0.00"` |
| 3 | `fee_charge_type` 能否作为写入字段 | **不是微信写入字段**。内扣的写入路径是公共参数 `out_fee_flag=2`(默认即 2);验收读回 `qry_wx_conf_list.fee_charge_type=1` 且 `out_fee_flag=2` | `fee_charge_type` 在业务开通与业务开通修改文档中均未出现在微信请求子对象,全文仅出现于花呗分期 `hb_fq_fee_config`;参考商户读回 `fee_charge_type=1`+`out_fee_flag=2` |
| 4 | 目标商户默认渠道是否 00016904 | **是真实生效渠道**;补开可不送 `pay_channel_id`(用渠道默认),补开后读回同形 | 参考商户 `pay_channel_id_list:["00016904"]`、`wx_mer_infos.pay_channel_id:"00016904"`、`is_default:"1"`,并有真实子商户号 `W15737921333056428494` |
#### CID 裁定(第 1 项)的依据
官方《微信费率规则ID》Excel(`wx-fee-rule-id.xlsx`,Sheet1 共 139 行,本轮重新下载并用 `DumpExcel.java`
全表导出核对,含合并区域):
- **企业 + `fee_type=01`(线上)+ 场景「4、线上公众号 5、线上小程序」只有一条规则:`fee_rule_id=756`**(B3:G9)。
  其适用行业为「提供网上交易场所或信息服务的业务、通讯业务、财经类业务及其他平台服务、医疗、交通出行等实体业务」,
  细分行业为电商平台、**婚介平台/就业信息平台/其他信息服务平台**、共享服务、机票票务代理、培训机构、
  停车缴费、私立民营口腔及眼科与医疗美容医院/诊所。本项目的人才卡/就业信息服务正落在
  「就业信息平台/其他信息服务平台」这一细分项上。
- **全表 `支付场景` 列的取值只有** 4/5(fee_type 01)、1/2/3(02)、10(03)、6(04)、11(05)、7(07)、8(08)、9(09)。
  **场景 12(线上反扫)在该表没有任何一行**;同时参考商户线上 4/5/12 三场景真实同为 0.70% 且已生效,
  说明 12 与 4/5 走同一线上规则,而不是另有一条 12 专属规则。
- 表里**没有**软件开发/技术服务/系统集成的任何条目,CID 不能由「软件开发」这个词直接查到;
  能对上的是「信息服务平台/就业信息平台」这一细分,对应 756。
- 企业场景下微信请求用 `fee_rule_id` 代替 `mcc`,合同明确「企业使用 fee_rule_id 代替原有 mcc,**mcc 可不填**」。
  因此商户基础 MCC 为 5732 并不阻塞微信业务补开。
仍建议真实补开前向汇付确认一句:主营软件开发/技术服务的**企业**,线上三场景是否统一用 756、
场景 12 是否随 4/5 生效。756 与「12 随 4/5」都有官方表与同渠道真实生效样本支撑,
但表里没有软件开发字样,这一句确认成本很低、能去掉最后一点歧义。
### 本轮附带确认到的事实
- **人才端 AppID 绑定形态**:参考商户读回 `atu_config_bo_list[0].wx_applet_app_id=wxcfee477acb54d179`,
  并在 `wx_config_list` 中以 `config_type=1` 出现、无 `control_flag`。目标商户补开与配置后应读回同样形状。
- **`APPLYMENT_STATE_FREEZED` 真实样本**:参考商户 `authorize_stat=1`(授权成功)却返回 FREEZED,
  原因是主体已注册过、需撤销申请单并做商户号授权。实现不能把 FREEZED 直接判为失败,也不能忽略
  `authorize_stat`;两者要一起展示并给出官方原因。印证了合同核查文档对 S3 的判断。
- **参考商户 `mcc=7372`**(对应「计算机软件开发、系统集成、数据处理服务」),线上 4/5/12 三场景 0.70% 真实生效;
  目标商户 `mcc=5732` 却已是「软件公司」简称。这是「行业与资质按实际情况使用」的现实依据,
  但按上面的裁定,它不构成补开的阻塞项。
- **本地库与通道不一致**:本地 `merchant_onboarding_application` id=449 仍是 `status=pending`、
  `wechat_config_status=not_started`、`business_open_status=not_started`,而通道已是 `apply_status=Y`。
  刷新必须能把基础阶段推进到通过,否则两端页面会一直显示与通道不符的「审核中」。
### 仍未确定
1. 目标商户的**实名/授权结果**。目标连微信都未入驻(`AT入驻信息池信息不存在`),必须先补开才会出现实名申请
   与二维码;实名与授权需商户本人扫码,属人工步骤。
2. 补开**是否会被渠道按行业退回**。756 有官方表与同渠道样本支撑,但表里没有软件开发条目,
   且目标商户基础 MCC 是 5732,实际受理结果只能以真实补开后的返回为准。
3. 目标商户**延迟入账尚未开启**(`delay_flag=N`),这是本轮之后唯一还明确的资金能力缺口。
### 本轮新增文件
| 文件 | 用途 |
| --- | --- |
| `QueryCapabilities.java` | 只读能力探针(六端点,含 SDK 异常时的原始 HTTP 诊断) |
| `InspectMerchant.java` | 按汇付商户号只读排查本地进件记录 |
| `DumpExcel.java` | 用 POI 导出官方费率规则 Excel 全表 |
| `huifu-fee-rate-query-doc.md` | 官方「商户费率信息查询」原文 |
| `huifu-wx-config-doc.md` | 官方「微信商户配置」原文 |
| `huifu-business-open-doc.md` | 官方「商户业务开通」原文 |
| `huifu-business-modify-doc.md` | 官方「商户业务开通修改」原文 |
| `huifu-multifee-query-doc.md` | 官方「商户多费率配置查询」原文 |
| `sb-_sidebar.md` | 官方 API 文档侧边栏全量清单 |
| `wx-fee-rule-id.xlsx` | 官方《微信费率规则ID》表 |
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java
@@ -10,19 +10,63 @@
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/** Keeps merchant onboarding additive columns available when Flyway is disabled in the deployment. */
/** 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)。
     *
     * <p>开发库(和任何 `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<String, String> 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<String, String> tableDdl() { return Map.copyOf(TABLES); }
    @Override
    public void run(ApplicationArguments args) {
        try {
            for (Map.Entry<String, String> table : TABLES.entrySet()) {
                if (!hasTable(table.getKey())) createTable(table.getKey(), table.getValue());
            }
            Map<String, String> columns = new LinkedHashMap<>();
            columns.put("merchant_name", "varchar(128) default null");
            columns.put("merchant_short_name", "varchar(128) default null");
@@ -118,6 +162,29 @@
        }
    }
    /** 与 {@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;
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializerTests.java
New file
@@ -0,0 +1,89 @@
package com.talentcard.server.business.merchant;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
/**
 * 进件新增的表和列在开发库({@code spring.flyway.enabled=false})只能靠
 * {@link MerchantOnboardingSchemaInitializer} 补建。
 *
 * <p>测试库有 Flyway,所以这两个断言在测试里看不出必要性:V144 漏登记时测试全绿,而开发库的
 * `GET /api/merchant/onboarding/status` 第一个请求就 500(`Table
 * 'talent_card.merchant_onboarding_progress' doesn't exist`)。这里查的是"迁移与初始化器是否
 * 同步"这件事本身,不依赖数据库。
 */
class MerchantOnboardingSchemaInitializerTests {
    /** V80 建的进件主表:它是加列和后续一切的前提,不属于"迁移后续新增"。 */
    private static final Set<String> BASELINE_TABLES = Set.of("merchant_onboarding_application");
    private static final Set<String> CONSTRAINT_KEYWORDS = Set.of("primary", "unique", "key", "index", "constraint", "foreign");
    @Test
    void ensuresEveryOnboardingTableAddedByAMigration() throws Exception {
        Set<String> createdByMigrations = new TreeSet<>();
        for (String migration : migrations()) {
            Matcher matcher = Pattern.compile("(?i)create table (?:if not exists )?(merchant_onboarding_[a-z_]+)").matcher(migration);
            while (matcher.find()) createdByMigrations.add(matcher.group(1).toLowerCase(Locale.ROOT));
        }
        createdByMigrations.removeAll(BASELINE_TABLES);
        assertThat(createdByMigrations).as("migrations creating onboarding tables must be discoverable").isNotEmpty();
        assertThat(MerchantOnboardingSchemaInitializer.tableDdl().keySet()).containsAll(createdByMigrations);
    }
    @Test
    void initializerTablesCarryTheSameColumnsAsTheMigrationThatCreatedThem() throws Exception {
        for (var table : MerchantOnboardingSchemaInitializer.tableDdl().entrySet()) {
            String migrationStatement = createTableStatement(table.getKey());
            assertThat(migrationStatement).as("no migration creates %s, so the initializer copy cannot be verified", table.getKey()).isNotNull();
            assertThat(columnsOf(table.getValue())).as("columns of %s", table.getKey()).isEqualTo(columnsOf(migrationStatement));
        }
    }
    /** 初始化器里的建表语句和迁移里必须一致,否则开发库与测试库的表结构会悄悄分叉。 */
    private static Set<String> columnsOf(String statement) {
        List<String> lines = List.of(statement.split("\n"));
        Set<String> columns = new LinkedHashSet<>();
        for (int index = 1; index < lines.size(); index++) { // 第 0 行是 create table 表头
            String definition = lines.get(index).strip().replaceFirst(",$", "");
            if (definition.isEmpty()) continue;
            if (definition.startsWith(")")) break; // 列清单结束,后面是表选项
            String name = definition.split("\\s+")[0].toLowerCase(Locale.ROOT);
            if (!CONSTRAINT_KEYWORDS.contains(name)) columns.add(name);
        }
        return columns;
    }
    private static String createTableStatement(String table) throws Exception {
        Matcher matcher = Pattern.compile("(?i)create table (?:if not exists )?" + table + "\\s*\\(").matcher("");
        for (String migration : migrations()) {
            matcher.reset(migration);
            if (matcher.find()) return migration.substring(matcher.start(), migration.indexOf(");", matcher.end()));
        }
        return null;
    }
    private static List<String> migrations() throws Exception {
        List<String> statements = new ArrayList<>();
        Resource[] resources = new PathMatchingResourcePatternResolver().getResources("classpath*:db/migration/*.sql");
        for (Resource resource : resources) {
            try (InputStream stream = resource.getInputStream()) {
                statements.add(new String(stream.readAllBytes(), StandardCharsets.UTF_8));
            }
        }
        assertThat(resources).as("migration files must be on the test classpath").isNotEmpty();
        return statements;
    }
}