yeg
2026-09-14 3f17710ad8349a24b5ae00748b460cc3e2f886c6
chore(scratch): 新增开发库数据回灌与工作台统计 SQL 排查工具

RestoreOldData 按列交集把旧快照的数据 INSERT IGNORE 回灌到开发库;StatisticsProbe 用反射取出工作台统计的 SQL 逐条执行,用来定位被异常处理器盖掉的真实报错。
2个文件已添加
263 ■■■■■ 已修改文件
.scratch/automatic-withdrawal/tools/RestoreOldData.java 182 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/automatic-withdrawal/tools/StatisticsProbe.java 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/automatic-withdrawal/tools/RestoreOldData.java
New file
@@ -0,0 +1,182 @@
import java.sql.*;
import java.util.*;
/**
 * 把旧开发库(默认 127.0.0.1:6134/talent_card)的业务数据导入项目本地库(默认 127.0.0.1:8134/talent_card)。
 *
 * <p>背景:8134 这个库的表在 09-13 被按最新迁移重建过,原先导入的那份数据没了;6134 上保存着
 * 09-11 从远程开发库导出的完整快照,两份库的 91 张业务表一一对应,因此可以按列交集回灌。
 *
 * <p>三条安全约束:
 * <ul>
 *   <li>只做 INSERT IGNORE:主键/唯一键冲突的行保持本地现状,支付验证的种子数据(进件、结算卡、商品)不被覆盖;</li>
 *   <li>只导入两张库都存在的表,列取交集,旧库多出来的列直接忽略;</li>
 *   <li>纯新增,不执行任何 DELETE / UPDATE / DROP。</li>
 * </ul>
 *
 * <p>用法:java RestoreOldData [--dry-run]
 */
public class RestoreOldData {
    private static final String SCHEMA = "talent_card";
    private static final String OLD_HOST = System.getenv().getOrDefault("OLD_DB_HOST", "127.0.0.1:6134");
    private static final String NEW_HOST = System.getenv().getOrDefault("NEW_DB_HOST", "127.0.0.1:8134");
    private static final String USER = System.getenv().getOrDefault("MYSQL_USERNAME", "root");
    private static final String PASSWORD = System.getenv().getOrDefault("MYSQL_PASSWORD", "123456");
    /** 参考数据与迁移元数据不参与回灌:前者本地已有,后者结构不同。 */
    private static final Set<String> SKIP = Set.of("flyway_schema_history", "bank_branch_directory");
    public static void main(String[] args) throws Exception {
        boolean dryRun = Arrays.asList(args).contains("--dry-run");
        String base = "jdbc:mysql://%s/" + SCHEMA + "?useUnicode=true&characterEncoding=utf8"
                + "&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false"
                + "&rewriteBatchedStatements=true";
        try (Connection old = DriverManager.getConnection(String.format(base, OLD_HOST), USER, PASSWORD);
             Connection fresh = DriverManager.getConnection(String.format(base, NEW_HOST), USER, PASSWORD)) {
            List<String> tables = tables(fresh);
            System.out.println("旧库 " + OLD_HOST + " -> 新库 " + NEW_HOST + ",共 " + tables.size() + " 张表"
                    + (dryRun ? "(干跑,不写入)" : ""));
            System.out.println();
            int totalRead = 0;
            int totalInserted = 0;
            int totalSkipped = 0;
            List<String> problems = new ArrayList<>();
            try (Statement statement = fresh.createStatement()) {
                statement.execute("set foreign_key_checks = 0");
                for (String table : tables) {
                    if (SKIP.contains(table)) {
                        System.out.printf("%-44s 跳过(参考数据/迁移元数据)%n", table);
                        continue;
                    }
                    List<String> oldColumns = columns(old, table);
                    List<String> newColumns = columns(fresh, table);
                    if (oldColumns.isEmpty()) {
                        problems.add(table + ":旧库没有这张表");
                        System.out.printf("%-44s 跳过(旧库无此表)%n", table);
                        continue;
                    }
                    Set<String> known = new HashSet<>();
                    for (String column : oldColumns) known.add(column.toLowerCase(Locale.ROOT));
                    List<String> shared = new ArrayList<>();
                    for (String column : newColumns) {
                        if (known.contains(column.toLowerCase(Locale.ROOT))) shared.add(column);
                    }
                    if (shared.isEmpty()) {
                        problems.add(table + ":没有可共用的列");
                        System.out.printf("%-44s 跳过(无共用列)%n", table);
                        continue;
                    }
                    Outcome outcome = dryRun ? readOnly(old, table, shared) : copy(old, fresh, table, shared);
                    totalRead += outcome.read;
                    totalInserted += outcome.inserted;
                    totalSkipped += outcome.skipped;
                    if (!outcome.errors.isEmpty()) problems.add(table + ":" + outcome.errors.get(0));
                    System.out.printf("%-44s 读出 %-6d 写入 %-6d 跳过 %-6d 列 %d/%d%s%n",
                            table, outcome.read, outcome.inserted, outcome.skipped, shared.size(), newColumns.size(),
                            outcome.errors.isEmpty() ? "" : "  错误 " + outcome.errors.size() + " 条");
                }
                statement.execute("set foreign_key_checks = 1");
            }
            System.out.println();
            System.out.println("================ 汇总 ================");
            System.out.println("读出 " + totalRead + " 行,写入 " + totalInserted + " 行,跳过(已存在)" + totalSkipped + " 行");
            if (!problems.isEmpty()) {
                System.out.println("需要注意:" + problems.size() + " 项");
                for (String problem : problems) System.out.println("  - " + problem);
            }
            System.out.println("=====================================");
        }
    }
    private record Outcome(int read, int inserted, int skipped, List<String> errors) {
    }
    private static List<String> tables(Connection connection) throws SQLException {
        List<String> tables = new ArrayList<>();
        try (PreparedStatement statement = connection.prepareStatement(
                "select table_name from information_schema.tables where table_schema = ? and table_type = 'BASE TABLE' order by table_name")) {
            statement.setString(1, SCHEMA);
            try (ResultSet resultSet = statement.executeQuery()) {
                while (resultSet.next()) tables.add(resultSet.getString(1));
            }
        }
        return tables;
    }
    private static List<String> columns(Connection connection, String table) throws SQLException {
        List<String> columns = new ArrayList<>();
        try (PreparedStatement statement = connection.prepareStatement(
                "select column_name from information_schema.columns where table_schema = ? and table_name = ? order by ordinal_position")) {
            statement.setString(1, SCHEMA);
            statement.setString(2, table);
            try (ResultSet resultSet = statement.executeQuery()) {
                while (resultSet.next()) columns.add(resultSet.getString(1));
            }
        }
        return columns;
    }
    private static Outcome readOnly(Connection old, String table, List<String> shared) throws SQLException {
        String select = "select " + quoted(shared) + " from `" + table + "`";
        int read = 0;
        try (Statement statement = old.createStatement()) {
            try (ResultSet resultSet = statement.executeQuery(select)) {
                while (resultSet.next()) read++;
            }
        }
        return new Outcome(read, 0, 0, List.of());
    }
    private static Outcome copy(Connection old, Connection fresh, String table, List<String> shared) throws SQLException {
        String select = "select " + quoted(shared) + " from `" + table + "`";
        String placeholders = String.join(", ", Collections.nCopies(shared.size(), "?"));
        String insert = "insert ignore into `" + table + "` (" + quoted(shared) + ") values (" + placeholders + ")";
        int read = 0;
        int inserted = 0;
        int skipped = 0;
        List<String> errors = new ArrayList<>();
        try (Statement reader = old.createStatement(ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) {
            reader.setFetchSize(Integer.MIN_VALUE);
            try (ResultSet resultSet = reader.executeQuery(select);
                 PreparedStatement writer = fresh.prepareStatement(insert)) {
                while (resultSet.next()) {
                    read++;
                    for (int index = 1; index <= shared.size(); index++) {
                        writer.setObject(index, resultSet.getObject(index));
                    }
                    try {
                        int affected = writer.executeUpdate();
                        if (affected > 0) inserted++;
                        else skipped++;
                    } catch (SQLException exception) {
                        errors.add("第 " + read + " 行:" + exception.getMessage());
                        if (errors.size() > 20) break;
                    }
                }
            }
        }
        return new Outcome(read, inserted, skipped, errors);
    }
    private static String quoted(List<String> columns) {
        StringBuilder builder = new StringBuilder();
        for (String column : columns) {
            if (builder.length() > 0) builder.append(", ");
            builder.append('`').append(column).append('`');
        }
        return builder.toString();
    }
}
.scratch/automatic-withdrawal/tools/StatisticsProbe.java
New file
@@ -0,0 +1,81 @@
import java.lang.reflect.*;
import java.sql.*;
import java.util.*;
/**
 * 逐条执行后台工作台统计用到的 SQL,定位真正报错的那一条。
 *
 * <p>工作台的异常处理器把所有异常都盖成"工作台统计查询失败",看不到堆栈,
 * 所以这里用反射取出编译后的 SQL(provider 方法 + 接口上的 @Select 注解),直接在库上跑一遍。
 */
public class StatisticsProbe {
    private static final String URL = System.getenv().getOrDefault("PROBE_DB_URL",
            "jdbc:mysql://127.0.0.1:8134/talent_card?useUnicode=true&characterEncoding=utf8"
                    + "&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false");
    private static final String USER = System.getenv().getOrDefault("MYSQL_USERNAME", "root");
    private static final String PASSWORD = System.getenv().getOrDefault("MYSQL_PASSWORD", "123456");
    public static void main(String[] args) throws Exception {
        try (Connection connection = DriverManager.getConnection(URL, USER, PASSWORD)) {
            System.out.println("目标库:" + URL);
            System.out.println();
            runProviders(connection);
            runAnnotated(connection);
        }
    }
    private static void runProviders(Connection connection) throws Exception {
        Class<?> type = Class.forName("com.talentcard.server.mapper.StatisticsQuerySql");
        Object instance = type.getDeclaredConstructor().newInstance();
        System.out.println("== StatisticsQuerySql(provider 拼接的 SQL) ==");
        for (String method : List.of("selectSummary", "selectCompareSummary", "selectSummaryTrend",
                "selectPeriodSummaries", "selectMerchantOverviews", "selectSettlementExportDetails", "selectDashboardCards")) {
            String sql;
            try {
                sql = (String) type.getMethod(method).invoke(instance);
            } catch (NoSuchMethodException exception) {
                System.out.printf("%-28s 无此方法%n", method);
                continue;
            }
            run(connection, method, sql);
        }
        System.out.println();
    }
    private static void runAnnotated(Connection connection) throws Exception {
        Class<?> mapper = Class.forName("com.talentcard.server.mapper.AdminWorkbenchStatisticsMapper");
        Class<?> select = Class.forName("org.apache.ibatis.annotations.Select");
        Method value = select.getMethod("value");
        System.out.println("== AdminWorkbenchStatisticsMapper(@Select 注解里的 SQL) ==");
        List<String> names = new ArrayList<>();
        for (Method method : mapper.getDeclaredMethods()) names.add(method.getName());
        Collections.sort(names);
        for (String name : names) {
            for (Method method : mapper.getDeclaredMethods()) {
                if (!method.getName().equals(name)) continue;
                Object annotation = method.getAnnotation((Class) select);
                if (annotation == null) {
                    System.out.printf("%-32s 跳过(provider 或无注解)%n", name);
                    continue;
                }
                String[] parts = (String[]) value.invoke(annotation);
                run(connection, name, String.join(" ", parts));
            }
        }
    }
    private static void run(Connection connection, String name, String sql) {
        try (Statement statement = connection.createStatement()) {
            statement.setQueryTimeout(30);
            try (ResultSet resultSet = statement.executeQuery(sql)) {
                int rows = 0;
                while (resultSet.next()) rows++;
                System.out.printf("%-32s OK   返回 %d 行%n", name, rows);
            }
        } catch (SQLException exception) {
            System.out.printf("%-32s 失败 %s%n", name, exception.getMessage());
        }
    }
}