yeg
2026-09-18 db704fbed062d5700a37b7858231bf7d0958e38d
chore(payment): 新增 5 个只读排查探针与运行脚本

接手的人遇到「这笔钱/这个商户在通道侧到底是什么状态」时不用回来找人:

PaymentStateProbe 比对某笔支付在本地与通道的真实状态,指出差异
MerchantCapabilityProbe 比对某商户的本地能力快照与通道 basicdata 真实值,并给出能否收款
OrderFundBoundaryProbe 打印某订单的资金边界、资金确认状态与相关差错记录
SettlementRecordProbe 直查通道结算记录(钉住 pageSize<=50)
CallbackUrlAuditProbe 回调地址自检,单跑版

严格只读:只允许 SELECT 与通道查询类接口,不提供任何写路径。输出脱敏,不打印私钥、
完整商户号或完整公网 IP。参数从命令行传入、不带默认地址——仓库里已有一批把 URL 写死
127.0.0.1:8134 的一次性探针,那个端口上是合并前的旧副本库,不是开发库。

运行方式沿用 docs/audits/2026-09-10/run-probe.ps1 的既有三步(取 classpath → javac → java),
额外提供 -SkipBuild,因为本仓库规定同一 checkout 一次只跑一个 Maven 构建。
12个文件已添加
2234 ■■■■■ 已修改文件
talent-card-server/scripts/probes/CallbackUrlAuditProbe.java 254 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/scripts/probes/MerchantCapabilityProbe.java 404 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/scripts/probes/OrderFundBoundaryProbe.java 368 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/scripts/probes/PaymentStateProbe.java 503 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/scripts/probes/README.md 182 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/scripts/probes/SettlementRecordProbe.java 241 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/scripts/probes/huifu-probe.properties.example 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/scripts/probes/run-CallbackUrlAuditProbe.ps1 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/scripts/probes/run-MerchantCapabilityProbe.ps1 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/scripts/probes/run-OrderFundBoundaryProbe.ps1 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/scripts/probes/run-PaymentStateProbe.ps1 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/scripts/probes/run-SettlementRecordProbe.ps1 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/scripts/probes/CallbackUrlAuditProbe.java
New file
@@ -0,0 +1,254 @@
import com.talentcard.server.business.payment.HuifuCallbackUrlAudit;
import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties;
import java.io.IOException;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
 * 只读排查探针:把五个汇付回调地址逐个念一遍({@code HuifuCallbackUrlAudit} 的单跑版)。
 *
 * <p>应用里那份 {@code HuifuCallbackUrlAudit} 只在启动时写日志,要回答「现在这个部署到底配了哪几个地址、
 * 有没有一个是空的」就得翻启动日志或重启服务。本探针不启动 Spring、不连数据库,直接按同一套取值规则
 * (同一个类、同一批 getter)把结论打出来。
 */
public class CallbackUrlAuditProbe {
    /** 公网 IP 只留主机段前两段。回调地址是公开可达的,整串贴出来等于把入口交出去。 */
    private static final Pattern AUTHORITY_IPV4 = Pattern.compile("^(\\d{1,3})\\.(\\d{1,3})\\.\\d{1,3}\\.\\d{1,3}(:\\d+)?$");
    /** {@code PaymentCallbackController} 的类级路径;下面五个端点都挂在它下面。 */
    private static final String CONTROLLER_PATH = "/api/payments/huifu/";
    /** 回调端点名(与 {@code HuifuCallbackUrlAudit} 的常量一致)、配置键、用途。 */
    private static final String[][] ENDPOINTS = {
            {"notify", "notify-url", "支付结果回调"},
            {"merchant-business-notify", "merchant-business-notify-url", "绑卡(业务开通)回调"},
            {"merchant-onboarding-notify", "merchant-onboarding-notify-url", "进件状态回调"},
            {"merchant-wechat-config-notify", "merchant-wechat-config-notify-url", "微信配置回调"},
            {"merchant-business-open-notify", "merchant-business-open-notify-url", "业务开通结果回调"},
    };
    /**
     * 回答什么问题:五个回调地址各自的配置值是什么(脱敏后)、哪个是空的、哪个是由 {@code notify-url}
     * 推导出来的、有没有哪个配了但服务端根本没有对应入口。
     *
     * <p>什么时候用它:回调没到、商户开通卡在「等待通道通知」、或者换了域名/IP 之后要确认通道侧到底会
     * 往哪个地址投递。它也回答一句常被忽略的话:<b>某个回调地址留空不等于「不回调」</b>——四个非支付回调
     * 在配置为空时会由 {@code notify-url} 推导出同主机同前缀的地址(见
     * {@code HuifuPaymentProperties#callbackUrl})。真正的「回调未启用」只有一种:那个地址为空串。
     *
     * <p>它不改任何状态:不启动 Spring、不连数据库、不连通道,只读一个配置文件。
     *
     * <p>参数格式(全部从命令行传,缺参数直接打用法退出;不给默认地址):
     * <pre>
     * --config=&lt;汇付配置文件路径&gt;                必填
     * 配置文件里的键(前缀 app.payment.huifu. 可写可不写):
     *   notify-url、merchant-business-notify-url、merchant-onboarding-notify-url、
     *   merchant-wechat-config-notify-url、merchant-business-open-notify-url、environment
     * 也可以用 --notify-url=... 等参数逐个覆盖配置文件里的同名项。
     * </pre>
     */
    public static void main(String[] args) {
        String configPath = required(args, "config");
        Properties source = readConfig(configPath);
        HuifuPaymentProperties properties = loadProperties(source, arg(args, "huifu-environment"), args);
        warnIfProduction(properties);
        System.out.println("只读探针:不启动 Spring、不连数据库、不连通道,只按配置算出这五个地址。");
        System.out.println("地址已脱敏:IPv4 只留前两段 + x.x,域名原样(它不是 IP),端口与路径照常打印。");
        HuifuCallbackUrlAudit audit = new HuifuCallbackUrlAudit(properties);
        Map<String, String> report = audit.report();
        System.out.println();
        System.out.println("== 五个回调地址(取值规则与启动时的 HuifuCallbackUrlAudit 完全同一套)==");
        List<String> empty = new ArrayList<>();
        List<String> derived = new ArrayList<>();
        List<String> badPath = new ArrayList<>();
        List<String> otherHost = new ArrayList<>();
        String baseHost = hostOf(report.get("notify"));
        for (int index = 0; index < ENDPOINTS.length; index++) {
            String name = ENDPOINTS[index][0];
            String configKey = ENDPOINTS[index][1];
            String purpose = ENDPOINTS[index][2];
            String raw = value(source, configKey, args);
            String effective = report.get(name) == null ? "" : report.get(name).trim();
            String state;
            if (effective.isBlank()) {
                state = "空 —— 回调未启用(通道不会往这里投递)";
                empty.add(name);
            } else if (raw.isBlank()) {
                state = "未显式配置,由 notify-url 推导";
                derived.add(name);
            } else if (raw.trim().equals(effective)) {
                state = "显式配置";
            } else {
                state = "显式配置(值为 " + maskHost(raw.trim()) + ",去掉了首尾空白后与推导结果一致)";
            }
            System.out.println();
            System.out.println("[" + (index + 1) + "/5] " + name + "  —— " + purpose);
            System.out.println("      配置键 " + configKey);
            System.out.println("      状态   " + state);
            System.out.println("      地址   " + (effective.isBlank() ? "(空)" : maskHost(effective)));
            if (!effective.isBlank()) {
                if (!effective.endsWith(CONTROLLER_PATH + name)) {
                    badPath.add(name);
                    System.out.println("      ! 路径不是 " + CONTROLLER_PATH + name
                            + ":PaymentCallbackController 只暴露那五个端点,打到别的路径会 404 且通道侧看起来"
                            + "「回调失败」。");
                }
                if (baseHost != null && !baseHost.equals(hostOf(effective))) {
                    otherHost.add(name);
                    System.out.println("      ! 主机与 notify-url 不同:那台机器也要能收到并处理这个回调,"
                            + "否则这个回调等于没有。");
                }
            }
        }
        System.out.println();
        System.out.println("== 结论 ==");
        System.out.println("  为空的地址(回调未启用):" + (empty.isEmpty() ? "无" : String.join("、", empty)));
        System.out.println("  由 notify-url 推导的地址:" + (derived.isEmpty() ? "无" : String.join("、", derived)));
        System.out.println("  路径与控制器对不上的:" + (badPath.isEmpty() ? "无" : String.join("、", badPath)));
        System.out.println("  主机与 notify-url 不同的:" + (otherHost.isEmpty() ? "无" : String.join("、", otherHost)));
        if (!derived.isEmpty()) {
            System.out.println("  推导的含义:这 " + derived.size() + " 项在你给的配置里没有值,它们的地址来自 notify-url 的"
                    + "同主机同前缀。要看清通道侧实际注册了哪个地址,请把这几个键照部署配置显式写进配置文件再跑一次。");
        }
        if (!empty.isEmpty()) {
            System.out.println("  空地址的后果:对应的通道回调不会被投递到任何地方,只能靠查单/对账把状态捞回来"
                    + "(支付有对账兜底;进件与业务开通那几条没有对账通道,会一直停在「等待通道通知」)。");
        }
        System.out.println("  另注:回调地址本身不管鉴权,通道回调一律要验签;地址公开不等于有人能伪造回调。");
    }
    // ---------- 参数、配置与输出 ----------
    private static Properties readConfig(String path) {
        Properties source = new Properties();
        try (Reader reader = Files.newBufferedReader(Path.of(path), StandardCharsets.UTF_8)) {
            source.load(reader);
        } catch (IOException exception) {
            System.out.println("读不了配置文件 " + path + ":" + exception.getMessage());
            usage();
        }
        return source;
    }
    /**
     * 键名兼容 {@code notify-url} 与 {@code app.payment.huifu.notify-url};命令行参数优先于文件。
     * 地址只在本探针内部使用,打印时一律经过 {@link #maskHost}。私钥、密钥类键根本不读。
     */
    private static String value(Properties source, String key, String[] args) {
        String fromArgs = arg(args, key);
        if (fromArgs != null && !fromArgs.isBlank()) return fromArgs.trim();
        String prefixed = source.getProperty("app.payment.huifu." + key);
        if (prefixed != null && !prefixed.isBlank()) return prefixed.trim();
        String plain = source.getProperty(key);
        return plain == null ? "" : plain.trim();
    }
    private static HuifuPaymentProperties loadProperties(Properties source, String environmentOverride, String[] args) {
        HuifuPaymentProperties properties = new HuifuPaymentProperties();
        properties.setEnvironment(firstNonBlank(environmentOverride,
                firstNonBlank(value(source, "environment", args), "test")));
        for (String[] endpoint : ENDPOINTS) {
            String url = value(source, endpoint[1], args);
            if (url.isBlank()) continue;
            // 显式赋值与「留空让 getter 推导」是两种不同的配置状态,这里只能把有值的写进去:
            // 写空串与不写,getter 的结果一样,但探针要靠「文件里有没有这个键」区分两者。
            switch (endpoint[1]) {
                case "notify-url" -> properties.setNotifyUrl(url);
                case "merchant-business-notify-url" -> properties.setMerchantBusinessNotifyUrl(url);
                case "merchant-onboarding-notify-url" -> properties.setMerchantOnboardingNotifyUrl(url);
                case "merchant-wechat-config-notify-url" -> properties.setMerchantWechatConfigNotifyUrl(url);
                case "merchant-business-open-notify-url" -> properties.setMerchantBusinessOpenNotifyUrl(url);
                default -> { }
            }
        }
        return properties;
    }
    private static String firstNonBlank(String first, String second) {
        if (first != null && !first.isBlank()) return first.trim();
        return second == null ? "" : second.trim();
    }
    /** 生产环境只提示、不拦截:本探针连通道都不连,环境不改变它能不能跑。 */
    private static void warnIfProduction(HuifuPaymentProperties properties) {
        if (!"prod".equalsIgnoreCase(properties.getEnvironment())) return;
        System.out.println("!! ================================================================================= !!");
        System.out.println("!! app.payment.huifu.environment = prod:这是生产环境的配置。                      !!");
        System.out.println("!! 本探针只读、连通道都不连(它只算地址),不会改变任何状态。                      !!");
        System.out.println("!! ================================================================================= !!");
    }
    /** 只留主机与路径形状:IPv4 留前两段 + x.x,域名原样(它不是 IP),端口与路径照常——要看的正是这两段。 */
    private static String maskHost(String value) {
        if (value == null || value.isBlank()) return "(空)";
        String trimmed = value.trim();
        int marker = trimmed.indexOf("//");
        if (marker < 0) return trimmed;
        String prefix = trimmed.substring(0, marker + 2);
        String rest = trimmed.substring(marker + 2);
        int end = rest.length();
        for (int index = 0; index < rest.length(); index++) {
            char current = rest.charAt(index);
            if (current == '/' || current == '?' || current == ';') { end = index; break; }
        }
        String authority = rest.substring(0, end);
        String tail = rest.substring(end);
        Matcher ipv4 = AUTHORITY_IPV4.matcher(authority);
        if (ipv4.matches()) {
            return prefix + ipv4.group(1) + "." + ipv4.group(2) + ".x.x"
                    + (ipv4.group(3) == null ? "" : ipv4.group(3)) + tail;
        }
        return prefix + authority + tail;
    }
    /** 取主机部分(含端口),用来比较几个回调地址是不是同一台机器。 */
    private static String hostOf(String url) {
        if (url == null || url.isBlank()) return null;
        String trimmed = url.trim();
        int marker = trimmed.indexOf("//");
        if (marker < 0) return null;
        String rest = trimmed.substring(marker + 2);
        int end = rest.length();
        for (int index = 0; index < rest.length(); index++) {
            char current = rest.charAt(index);
            if (current == '/' || current == '?' || current == ';') { end = index; break; }
        }
        return rest.substring(0, end);
    }
    private static String arg(String[] args, String name) {
        for (int index = 0; index < args.length; index++) {
            String current = args[index];
            if (current.equals("--" + name)) return index + 1 < args.length ? args[index + 1] : null;
            if (current.startsWith("--" + name + "=")) return current.substring(name.length() + 3);
        }
        return null;
    }
    private static String required(String[] args, String name) {
        String value = arg(args, name);
        if (value == null || value.isBlank()) {
            System.out.println("缺少必填参数 --" + name);
            usage();
        }
        return value.trim();
    }
    private static void usage() {
        System.out.println("用法:CallbackUrlAuditProbe --config=<汇付配置文件> [--huifu-environment=test|prod]"
                + " [--notify-url=...] [--merchant-business-notify-url=...] [--merchant-onboarding-notify-url=...]"
                + " [--merchant-wechat-config-notify-url=...] [--merchant-business-open-notify-url=...]");
        System.exit(2);
    }
}
talent-card-server/scripts/probes/MerchantCapabilityProbe.java
New file
@@ -0,0 +1,404 @@
import com.talentcard.server.business.merchant.MerchantPaymentReadinessService;
import com.talentcard.server.business.payment.huifu.HuifuMerchantCapabilityGateway;
import com.talentcard.server.business.payment.huifu.HuifuMerchantCapabilityQueryCommand;
import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties;
import java.io.IOException;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.support.TransactionTemplate;
/**
 * 只读排查探针:这家商户「能不能收款」,本地能力快照说行不行,通道侧又是什么值。
 *
 * <p>口径与下单闸门同源:最终结论取 {@link MerchantPaymentReadinessService#readiness},本探针只负责把
 * 它的输入摆出来(本地快照 <-> 通道真值),省得为了看懂一句「商户收款能力待确认」再去翻库。
 */
public class MerchantCapabilityProbe {
    /** 公网 IP 只留主机段前两段:地址本身(端口+路径)就是攻击面。 */
    private static final Pattern AUTHORITY_IPV4 = Pattern.compile("^(\\d{1,3})\\.(\\d{1,3})\\.\\d{1,3}\\.\\d{1,3}(:\\d+)?$");
    /**
     * 回答什么问题:给定商户 ID / 进件 ID / 汇付号,本地 {@code merchant_onboarding_application} 上的
     * 能力快照({@code merchant_stat} / {@code auto_settle_status} / {@code cash_switch_status} /
     * {@code cash_type} / {@code capability_verified_at})与通道 {@code v2/merchant/basicdata/query} 的
     * 真值差在哪,以及按 {@code MerchantPaymentReadinessService} 的规则算出来现在能不能收款。
     *
     * <p>什么时候用它:下单被拒「商户收款能力待确认 / 取现能力不可用 / 子商户状态异常」,或者后台商户页
     * 的收款按钮灰着——先看是本地快照过期,还是通道那边真的被关停了。
     *
     * <p>它不改任何状态:只 SELECT 与通道查询。特别地,<b>它绝不调用</b>
     * {@code refresh} / {@code capability} / {@code refreshQuietly} / {@code merchantVerify}
     * ——那四个都会把通道结果写回快照({@code update merchant_onboarding_application}),属于写操作;
     * 要刷新快照请用后台的「立即核实」,那条路径有冷却与并发保护,探针不走它。
     *
     * <p>参数格式(全部从命令行传,缺参数直接打用法退出,不用任何默认值瞎猜):
     * <pre>
     * --db-url=&lt;JDBC URL&gt; --db-user=&lt;用户&gt; --db-password=&lt;口令&gt;
     * 三者之一:--application-id=&lt;进件ID&gt; | --huifu-id=&lt;斗拱商户号&gt; | --merchant-id=&lt;本地商户ID&gt;
     * [--store-id=&lt;门店ID&gt;]            判定「能不能收款」时按这个门店问;默认用命中进件自己的 store_id
     * [--config=&lt;汇付配置文件路径&gt;]     不给就只查本地,且判定会退化成「跳过通道确认」那一档
     * [--huifu-environment=test|prod]
     * </pre>
     */
    public static void main(String[] args) {
        String dbUrl = required(args, "db-url");
        String dbUser = required(args, "db-user");
        String dbPassword = required(args, "db-password");
        String applicationId = arg(args, "application-id");
        String huifuId = arg(args, "huifu-id");
        String merchantId = arg(args, "merchant-id");
        if (blank(applicationId) && blank(huifuId) && blank(merchantId)) {
            System.out.println("必须给一个定位方式:--application-id=<进件ID>、--huifu-id=<斗拱商户号> 或 --merchant-id=<本地商户ID>。");
            usage();
        }
        String configPath = arg(args, "config");
        HuifuPaymentProperties properties = blank(configPath)
                ? new HuifuPaymentProperties() : loadProperties(configPath, arg(args, "huifu-environment"));
        warnIfProduction(blank(configPath), properties);
        System.out.println("只读探针:只做 SELECT 与通道的商户信息查询。不发起支付、不关单、不退款、不交易确认、"
                + "不下发业务开通、不刷新能力快照、不改任何数据库行。");
        System.out.println("数据库目标:" + maskHost(dbUrl));
        DriverManagerDataSource dataSource = new DriverManagerDataSource(dbUrl, dbUser, dbPassword);
        JdbcTemplate jdbc = new JdbcTemplate(dataSource);
        List<Map<String, Object>> rows = jdbc.queryForList("""
                select id, merchant_id, store_id, application_no, subject_type, subject_name, status,
                       provider_huifu_id, provider_apply_no, initial_bank_card_id,
                       merchant_stat, auto_settle_status, cash_switch_status, cash_type,
                       capability_verified_at, capability_sync_until, capability_response_code,
                       capability_response_message, business_open_status, wechat_config_status, updated_at
                from merchant_onboarding_application
                where (? is not null and id = ?)
                   or (? is not null and provider_huifu_id = ?)
                   or (? is not null and merchant_id = ?)
                order by id desc
                """, number(applicationId), number(applicationId),
                blank(huifuId) ? null : huifuId.trim(), blank(huifuId) ? null : huifuId.trim(),
                number(merchantId), number(merchantId));
        if (rows.isEmpty()) {
            System.out.println();
            System.out.println("本地没有匹配的进件。注意 --application-id / --merchant-id 要传数字,"
                    + "--huifu-id 是通道号(本地列是 provider_huifu_id)。");
            return;
        }
        // readiness() 只在本地按规则判定、不问通道;但它构造时要求传入 gateway 与 properties,
        // 因此这里传真实实例,同时绝不调用会写库的 refresh / capability / merchantVerify。
        MerchantPaymentReadinessService readinessService = new MerchantPaymentReadinessService(
                jdbc, new HuifuMerchantCapabilityGateway(properties), properties,
                new TransactionTemplate(new DataSourceTransactionManager(dataSource)));
        for (Map<String, Object> row : rows) {
            localSnapshot(row, jdbc);
            askProvider(row, properties);
            verdict(row, readinessService, arg(args, "store-id"));
            System.out.println();
        }
    }
    private static void localSnapshot(Map<String, Object> row, JdbcTemplate jdbc) {
        section("本地能力快照 / merchant_onboarding_application#" + row.get("id"));
        line("application_no        ", text(row.get("application_no")));
        line("merchant_id / store_id", row.get("merchant_id") + " / " + row.get("store_id"));
        line("subject_type / name   ", text(row.get("subject_type")) + " / " + text(row.get("subject_name")));
        line("status                ", text(row.get("status")));
        line("provider_huifu_id     ", maskId(row.get("provider_huifu_id")));
        line("provider_apply_no     ", maskId(row.get("provider_apply_no")));
        line("initial_bank_card_id  ", nullable(row.get("initial_bank_card_id")));
        line("merchant_stat         ", nullable(row.get("merchant_stat")));
        line("auto_settle_status    ", nullable(row.get("auto_settle_status")));
        line("cash_switch_status    ", nullable(row.get("cash_switch_status")));
        line("cash_type             ", nullable(row.get("cash_type")));
        line("capability_verified_at", nullable(row.get("capability_verified_at")));
        line("capability_sync_until ", nullable(row.get("capability_sync_until")));
        line("capability_response   ", nullable(row.get("capability_response_code")) + " " + nullable(row.get("capability_response_message")));
        line("business_open_status  ", nullable(row.get("business_open_status")));
        line("wechat_config_status  ", nullable(row.get("wechat_config_status")));
        line("updated_at            ", nullable(row.get("updated_at")));
        System.out.println("  快照为空(NULL)的含义:这一行**没有**核实过「通道侧现在是什么状态」,"
                + "不等于通道那边有问题,也不等于没问题。");
        Long id = longValue(row.get("id"));
        List<Map<String, Object>> cards = jdbc.queryForList("""
                select id, store_id, status, card_tail, provider_token_no is not null as has_token
                from merchant_bank_card where merchant_id = ? order by id desc limit 5
                """, row.get("merchant_id"));
        section("结算银行卡(本地 merchant_bank_card,只印后 4 位;token 只印有无)");
        if (cards.isEmpty()) {
            System.out.println("  这家商户没有任何结算银行卡记录——下单闸门会先拒「结算银行卡尚未绑定成功」。");
        }
        for (Map<String, Object> card : cards) {
            line("  #" + card.get("id"), "store_id=" + card.get("store_id") + " | " + card.get("status")
                    + " | 尾号=" + card.get("card_tail") + " | 有通道 token=" + card.get("has_token"));
        }
        List<Map<String, Object>> progress = jdbc.queryForList(
                "select stages_json, last_synced_at, sync_error from merchant_onboarding_progress where application_id = ?", id);
        section("分阶段进度(微信收款权限的证据在 stages_json 的 wechat / realname 两项)");
        if (progress.isEmpty()) {
            System.out.println("  没有进度记录:闸门会把「微信支付业务开通」和「微信实名与授权」都算缺"
                    + "(查不到证据就不能认为能收款)。");
        }
        for (Map<String, Object> stage : progress) {
            line("  stages_json  ", text(stage.get("stages_json")));
            line("  last_synced_at", nullable(stage.get("last_synced_at")));
            line("  sync_error   ", nullable(stage.get("sync_error")));
        }
    }
    private static void askProvider(Map<String, Object> row, HuifuPaymentProperties properties) {
        section("通道真值 / v2/merchant/basicdata/query");
        String huifuId = text(row.get("provider_huifu_id"));
        if (huifuId.isBlank()) {
            System.out.println("本地没有 provider_huifu_id,无法问通道:通道查询必须先说明问哪家商户。");
            return;
        }
        if (blank(properties.getSystemId())) {
            System.out.println("未给 --config(或配置里没有 system-id),跳过通道查询。"
                    + "要问通道请附上配置文件(见 scripts/probes/README.md)。");
            return;
        }
        line("huifuId", maskId(huifuId));
        HuifuMerchantCapabilityGateway.CapabilityResult result;
        try {
            result = new HuifuMerchantCapabilityGateway(properties)
                    .query(new HuifuMerchantCapabilityQueryCommand("MC" + System.currentTimeMillis(), huifuId));
        } catch (RuntimeException exception) {
            System.out.println("通道查询失败:" + exception.getClass().getSimpleName() + " " + text(exception.getMessage()));
            System.out.println("读法:通道没给出结论时不要往本地补结论。");
            return;
        }
        line("succeeded             ", String.valueOf(result.succeeded()));
        line("resp_code / desc      ", text(result.code()) + " " + text(result.message()));
        line("mer_stat(local列名 merchant_stat)", text(result.merchantStatus()));
        line("settle_status         ", text(result.settleStatus()));
        line("settle_cycle          ", text(result.settleCycle()));
        line("cash switch_state     ", text(result.cashSwitchStatus()));
        line("cash_type             ", text(result.cashType()));
        section("差异");
        boolean found = false;
        found |= diff("merchant_stat", row.get("merchant_stat"), result.merchantStatus(),
                "通道返回值决定「子商户状态异常」这条拦截。NORMAL 之外都要当成被关停处理。");
        found |= diff("auto_settle_status", row.get("auto_settle_status"), result.settleStatus(),
                "这一项**不再是**收款闸门:未核销的钱由延迟户兜住,自动结算开着也不会提前结走。只作参考。");
        found |= diff("cash_switch_status", row.get("cash_switch_status"), result.cashSwitchStatus(),
                "闸门要求取现可用(1 或 Y);这一项为 1/Y 之外会直接拒「商户取现能力不可用」。");
        found |= diff("cash_type", row.get("cash_type"), result.cashType(), "取现类型,随取现状态一起看。");
        if (blank(text(row.get("capability_verified_at")))) {
            found = true;
            System.out.println("* capability_verified_at 为空:本地快照从没核实过。"
                    + "在要求通道确认的部署上,下单闸门会直接拒「商户收款能力待确认」,"
                    + "与通道那边实际是否正常无关——这就是「明明通道正常却收不了款」的常见原因。");
        }
        if (!found) {
            System.out.println("* 本地快照与通道真值一致。");
        }
    }
    /** 逐列比本地快照与通道真值;两边都空时不算差异(没核实 ≠ 不一致)。 */
    private static boolean diff(String column, Object localValue, String providerValue, String note) {
        String local = text(localValue);
        String provider = text(providerValue);
        if (provider.isBlank() && local.isBlank()) return false;
        if (local.equalsIgnoreCase(provider)) return false;
        System.out.println("* " + column + ":本地 " + (local.isBlank() ? "(NULL)" : local)
                + " vs 通道 " + (provider.isBlank() ? "(未给)" : provider) + "。 " + note);
        return true;
    }
    /** 最终结论直接调用闸门自己的服务,不在这里重写一遍规则。 */
    private static void verdict(Map<String, Object> row, MerchantPaymentReadinessService readinessService, String storeIdOverride) {
        section("按 MerchantPaymentReadinessService 的规则算出来能不能收款");
        long merchantId = longValue(row.get("merchant_id"));
        Long storeId = blank(storeIdOverride) ? longValue(row.get("store_id")) : number(storeIdOverride);
        if (storeId == null || storeId == 0L) {
            storeId = null;
            System.out.println("  该进件没有 store_id(历史数据允许为空),按「该商户最新一条已通过的进件」判定。");
        }
        try {
            MerchantPaymentReadinessService.Readiness readiness = readinessService.readiness(merchantId, storeId);
            System.out.println("  ready  : " + readiness.ready());
            if (readiness.ready()) {
                MerchantPaymentReadinessService.CollectingMerchant merchant = readiness.merchant();
                System.out.println("  收款进件: #" + merchant.applicationId() + " store_id=" + merchant.storeId()
                        + " huifuId=" + maskId(merchant.huifuId()));
                System.out.println("  订单会把这组值固定下来(collect_onboarding_id / collect_store_id / collect_huifu_id)。");
            } else {
                System.out.println("  原因   : " + readiness.reason());
                System.out.println("  这句文案与用户下单时被拒的文案同源:看到它就等于看到用户看到的那句。");
            }
            System.out.println("  通道侧该进件是否正常(channelCapabilityNormal): "
                    + readinessService.channelCapabilityNormal(longValue(row.get("id"))));
            System.out.println("  两者读法不同:channelCapabilityNormal 只回答「通道那边开通正常吗」,"
                    + "ready 才回答「现在能不能收款」(它还要微信/实名与绑卡前置)。");
        } catch (RuntimeException exception) {
            System.out.println("  判定失败:" + exception.getClass().getSimpleName() + " " + text(exception.getMessage()));
            System.out.println("  排查读法:本地库缺列(开发库结构不由迁移维护)会让这里的查询直接报错,"
                    + "见 docs/agents/testing.md。");
        }
    }
    // ---------- 参数、配置与输出 ----------
    /** 读汇付配置。<b>不读 application.yml</b>:私钥与密钥只在本探针内部使用,从不打印。 */
    private static HuifuPaymentProperties loadProperties(String path, String environmentOverride) {
        Properties source = new Properties();
        try (Reader reader = Files.newBufferedReader(Path.of(path), StandardCharsets.UTF_8)) {
            source.load(reader);
        } catch (IOException exception) {
            System.out.println("读不了配置文件 " + path + ":" + exception.getMessage());
            usage();
        }
        HuifuPaymentProperties properties = new HuifuPaymentProperties();
        String enabled = value(source, "enabled");
        properties.setEnabled(enabled.isBlank() || Boolean.parseBoolean(enabled));
        properties.setEnvironment(firstNonBlank(environmentOverride, firstNonBlank(value(source, "environment"), "test")));
        apply(value(source, "system-id"), properties::setSystemId);
        apply(value(source, "product-id"), properties::setProductId);
        apply(value(source, "merchant-id"), properties::setMerchantId);
        apply(value(source, "mini-program-app-id"), properties::setMiniProgramAppId);
        apply(value(source, "private-key"), properties::setPrivateKey);
        apply(value(source, "platform-public-key"), properties::setPlatformPublicKey);
        apply(value(source, "notify-url"), properties::setNotifyUrl);
        // 这一项决定闸门要不要「快照为空就拒」。配置文件里没写时它会回落到 enabled,
        // 所以只要 enabled=true 就与线上默认(true)一致。
        String capabilityRequired = value(source, "merchant-capability-required");
        if (!capabilityRequired.isBlank()) properties.setMerchantCapabilityRequired(Boolean.parseBoolean(capabilityRequired));
        return properties;
    }
    /** 键名兼容 {@code system-id} 与 {@code app.payment.huifu.system-id} 两种写法(后者照抄部署配置)。 */
    private static String value(Properties source, String key) {
        return firstNonBlank(source.getProperty("app.payment.huifu." + key), source.getProperty(key));
    }
    private static String firstNonBlank(String first, String second) {
        if (first != null && !first.isBlank()) return first.trim();
        return second == null ? "" : second.trim();
    }
    private static void apply(String value, java.util.function.Consumer<String> setter) {
        if (value != null && !value.isBlank()) setter.accept(value.trim());
    }
    private static void warnIfProduction(boolean withoutConfig, HuifuPaymentProperties properties) {
        if (withoutConfig) {
            System.out.println("! 未提供 --config:本地快照照常打印,但通道真值只能是空白,且「能不能收款」的判定会"
                    + "退化成「跳过通道确认」那一档(merchant_capability_required=false),与线上默认**不一致**。"
                    + "要线上口径的答案请附上配置文件。");
            return;
        }
        if (!"prod".equalsIgnoreCase(properties.getEnvironment())) return;
        System.out.println("!! ================================================================================= !!");
        System.out.println("!! app.payment.huifu.environment = prod:目标是生产环境。                          !!");
        System.out.println("!! 本探针只读(只 SELECT + 通道查询),不刷新快照、不下发业务开通、不改库。            !!");
        System.out.println("!! 但查询会留在通道的调用记录里,排查请按生产操作对待。                            !!");
        System.out.println("!! ================================================================================= !!");
    }
    /**
     * 商户号 / 汇付号只留前 6 位。通道号是 16 位数字,整串贴出来等于把可枚举的账号交出去;
     * 前 6 位足够看出几条记录是不是同一个商户。
     */
    private static String maskId(Object value) {
        String text = text(value);
        if (text.isBlank()) return "(空)";
        return text.length() <= 6 ? "***" : text.substring(0, 6) + "***";
    }
    /** 只留主机与路径形状:IPv4 留前两段 + x.x,域名原样(它不是 IP),端口与路径照常。 */
    private static String maskHost(String value) {
        if (value == null || value.isBlank()) return "(空)";
        int marker = value.indexOf("//");
        if (marker < 0) return value.trim();
        String prefix = value.substring(0, marker + 2);
        String rest = value.substring(marker + 2);
        int end = rest.length();
        for (int index = 0; index < rest.length(); index++) {
            char current = rest.charAt(index);
            if (current == '/' || current == '?' || current == ';') { end = index; break; }
        }
        String authority = rest.substring(0, end);
        String tail = rest.substring(end);
        Matcher ipv4 = AUTHORITY_IPV4.matcher(authority);
        if (ipv4.matches()) {
            return prefix + ipv4.group(1) + "." + ipv4.group(2) + ".x.x" + (ipv4.group(3) == null ? "" : ipv4.group(3)) + tail;
        }
        return prefix + authority + tail;
    }
    /** 命令行上给的 ID 必须是数字:拿它去比 bigint 列,传错类型只会得到「查不到」,很难看出是自己传错了。 */
    private static Long number(String value) {
        if (value == null || value.isBlank()) return null;
        try {
            return Long.valueOf(value.trim());
        } catch (NumberFormatException exception) {
            System.out.println("参数 --...=" + value + " 不是数字 ID。");
            usage();
            return null;
        }
    }
    private static Long longValue(Object value) {
        return value instanceof Number number ? number.longValue() : null;
    }
    private static String arg(String[] args, String name) {
        for (int index = 0; index < args.length; index++) {
            String current = args[index];
            if (current.equals("--" + name)) return index + 1 < args.length ? args[index + 1] : null;
            if (current.startsWith("--" + name + "=")) return current.substring(name.length() + 3);
        }
        return null;
    }
    private static String required(String[] args, String name) {
        String value = arg(args, name);
        if (value == null || value.isBlank()) {
            System.out.println("缺少必填参数 --" + name);
            usage();
        }
        return value.trim();
    }
    private static void usage() {
        System.out.println("用法:MerchantCapabilityProbe --db-url=<JDBC URL> --db-user=<用户> --db-password=<口令>"
                + " (--application-id=<进件ID> | --huifu-id=<斗拱商户号> | --merchant-id=<本地商户ID>)"
                + " [--store-id=<门店ID>] [--config=<汇付配置文件>] [--huifu-environment=test|prod]");
        System.exit(2);
    }
    private static void section(String title) {
        System.out.println();
        System.out.println("== " + title + " ==");
    }
    private static void line(String key, String value) {
        System.out.println("  " + key + " : " + value);
    }
    private static String text(Object value) {
        return value == null ? "" : String.valueOf(value).trim();
    }
    private static String nullable(Object value) {
        String text = text(value);
        return text.isBlank() ? "(NULL)" : text;
    }
    private static boolean blank(String value) {
        return value == null || value.isBlank();
    }
}
talent-card-server/scripts/probes/OrderFundBoundaryProbe.java
New file
@@ -0,0 +1,368 @@
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
/**
 * 只读排查探针:一笔订单的「资金边界」——钱收没收到、核销没核销、退没退款——以及挂在它上面的通道记录。
 *
 * <p>这三列是同一行上的三个判断({@code benefit_order}),核销、退款、资金确认都靠锁定这一行来串行化,
 * 所以任何一笔钱的问题都要先把这三列读出来。
 */
public class OrderFundBoundaryProbe {
    /** 公网 IP 只留主机段前两段:地址本身(端口+路径)就是攻击面。 */
    private static final Pattern AUTHORITY_IPV4 = Pattern.compile("^(\\d{1,3})\\.(\\d{1,3})\\.\\d{1,3}\\.\\d{1,3}(:\\d+)?$");
    /**
     * 回答什么问题:给定订单号,本地 {@code benefit_order} 的资金边界三列
     * ({@code order_status} / {@code verify_status} / {@code refund_status})、创建时固定的收款方
     * ({@code collect_onboarding_id} / {@code collect_store_id} / {@code collect_huifu_id})、
     * 关联的支付流水与退款记录、核销记录,以及 {@code payment_reconciliation_issue} 里相关的未结问题。
     *
     * <p>什么时候用它:用户说「付了钱订单没变」「核销了商户没拿到钱」「退款了钱没退回来」。它把一条订单
     * 上所有与钱有关的事实一次摆齐,再决定去哪条链路(对账、资金确认、悬账退款)继续查。
     *
     * <p>它不改任何状态:整个探针只有 SELECT,连通道都不问(因此不需要汇付配置)。
     *
     * <p>参数格式(全部从命令行传,缺参数直接打用法退出,不用任何默认值瞎猜):
     * <pre>
     * --db-url=&lt;JDBC URL&gt; --db-user=&lt;用户&gt; --db-password=&lt;口令&gt;
     * --order-no=&lt;订单号&gt;
     * [--config=&lt;汇付配置文件路径&gt;]     只为读 environment 打生产警告,本探针不做通道查询
     * [--huifu-environment=test|prod]    同上,效果等价且在只给这一项时更方便
     * </pre>
     */
    public static void main(String[] args) {
        String dbUrl = required(args, "db-url");
        String dbUser = required(args, "db-user");
        String dbPassword = required(args, "db-password");
        String orderNo = required(args, "order-no");
        environmentBanner(arg(args, "config"), arg(args, "huifu-environment"));
        System.out.println("只读探针:整个探针只有 SELECT,连通道都不问。不发起支付、不关单、不退款、不交易确认、"
                + "不下发业务开通、不改任何数据库行。");
        System.out.println("数据库目标:" + maskHost(dbUrl));
        JdbcTemplate jdbc = new JdbcTemplate(new DriverManagerDataSource(dbUrl, dbUser, dbPassword));
        List<Map<String, Object>> orders = jdbc.queryForList("""
                select id, order_no, order_type, talent_id, card_id, merchant_id, store_id,
                       order_amount, subsidy_amount, pay_amount, order_status, verify_status, refund_status,
                       collect_onboarding_id, collect_store_id, collect_huifu_id,
                       expires_at, cancel_requested_at, deleted, created_at, updated_at
                from benefit_order where order_no = ?
                """, orderNo);
        if (orders.isEmpty()) {
            System.out.println();
            System.out.println("本地没有这个订单号:" + orderNo);
            return;
        }
        for (Map<String, Object> order : orders) {
            boundary(order);
            payments(jdbc, order);
            refunds(jdbc, order);
            verifications(jdbc, order);
            issues(jdbc, order);
            System.out.println();
        }
    }
    private static void boundary(Map<String, Object> order) {
        // 状态列是 tinyint,取成 int 才能用 switch(Java 的 switch 不支持 long 选择子)。
        int orderStatus = (int) longValue(order.get("order_status"), -1L);
        int verifyStatus = (int) longValue(order.get("verify_status"), -1L);
        int refundStatus = (int) longValue(order.get("refund_status"), -1L);
        String orderStatusText = switch (orderStatus) {
            case 0 -> "待支付";
            case 1 -> "已支付";
            case 2 -> "已取消";
            default -> "";
        };
        String refundStatusText = switch (refundStatus) {
            // 注意两套取值不一样:benefit_order.refund_status 是 0未退款 1退款中 2已退款,
            // 而 order_refund.refund_status 是 1退款中 2退款成功 3退款失败(见下面的退款记录)。
            case 0 -> "未退款";
            case 1 -> "退款中";
            case 2 -> "已退款";
            default -> "";
        };
        section("资金边界 / benefit_order#" + order.get("id"));
        line("order_no         ", text(order.get("order_no")));
        line("order_type       ", text(order.get("order_type")));
        line("merchant_id / store_id", order.get("merchant_id") + " / " + order.get("store_id"));
        line("order_status     ", orderStatus + "  " + orderStatusText);
        line("verify_status    ", verifyStatus + "  " + (verifyStatus == 1 ? "已核销" : verifyStatus == 0 ? "未核销" : ""));
        line("refund_status    ", refundStatus + "  " + refundStatusText);
        line("pay / order / subsidy", text(order.get("pay_amount")) + " / " + text(order.get("order_amount"))
                + " / " + text(order.get("subsidy_amount")) + " 分");
        line("collect_onboarding_id", nullable(order.get("collect_onboarding_id")));
        line("collect_store_id ", nullable(order.get("collect_store_id")));
        line("collect_huifu_id ", maskId(order.get("collect_huifu_id")));
        line("expires_at       ", nullable(order.get("expires_at")));
        line("cancel_requested_at", nullable(order.get("cancel_requested_at")));
        line("created / updated", nullable(order.get("created_at")) + " / " + nullable(order.get("updated_at")));
        if (longValue(order.get("deleted"), 0L) == 1L) {
            System.out.println("  ! deleted=1:这行被软删了。软删的订单也可能带着真实的支付流水,"
                    + "排查时不要跳过它。");
        }
        System.out.println("  读法:钱进商户基本户要满足 order_status=1 且 verify_status=1 且 refund_status=0"
                + "(资金确认重试就是这么筛的)。退款在途(refund_status=1)会让整单退出可核销与可确认范围。");
        if (longValue(order.get("collect_onboarding_id"), 0L) == 0L) {
            System.out.println("  ! collect_onboarding_id 为空:这笔订单创建时没有固定收款子商户。"
                    + "V131 之前的订单由平台商户收款,退款时按 HuifuRefundService 的那条回退走平台商户号;"
                    + "现在下的单不该出现这种值。");
        }
    }
    private static void payments(JdbcTemplate jdbc, Map<String, Object> order) {
        List<Map<String, Object>> rows = jdbc.queryForList("""
                select id, payment_no, provider, provider_huifu_id, amount, status, funds_confirmation_status,
                       idempotency_key, attempt_no, provider_transaction_no, close_attempts,
                       created_at, paid_at, closed_at
                from payment_transaction where order_id = ? or order_no = ? order by id asc
                """, order.get("id"), order.get("order_no"));
        section("支付流水(payment_transaction,共 " + rows.size() + " 条)");
        if (rows.isEmpty()) {
            System.out.println("  一条都没有:这笔订单从来没有发起过支付(0 元单或纯补贴单也可能是这样)。");
            return;
        }
        for (Map<String, Object> row : rows) {
            line("  #" + row.get("id"), text(row.get("payment_no")) + " | " + text(row.get("provider"))
                    + " | status=" + text(row.get("status"))
                    + " | funds=" + nullable(row.get("funds_confirmation_status"))
                    + " | " + text(row.get("amount")) + " 分"
                    + " | provider_huifu_id=" + maskId(row.get("provider_huifu_id")));
            line("    幂等键/尝试", text(row.get("idempotency_key")) + " / #" + text(row.get("attempt_no"))
                    + " | close_attempts=" + text(row.get("close_attempts"))
                    + " | hf_seq=" + nullable(row.get("provider_transaction_no")));
            line("    时间       ", nullable(row.get("created_at")) + " -> paid " + nullable(row.get("paid_at"))
                    + " / closed " + nullable(row.get("closed_at")));
        }
        System.out.println("  读法:funds_confirmation_status NULL=这笔没走延迟入账;pending=钱还在通道延迟户"
                + "(不可结算、不可取现);confirmed=已进基本户;reverting=回退在途(撤销核销要把钱要回来)。"
                + "状态是 CLOSING / CANCEL_UNCONFIRMED 表示这次尝试的关单还没结论。");
    }
    private static void refunds(JdbcTemplate jdbc, Map<String, Object> order) {
        List<Map<String, Object>> rows = jdbc.queryForList("""
                select id, refund_no, refund_status, paid_amount, refund_amount, refundable_amount, provider,
                       provider_refund_no, provider_original_no, provider_huifu_id,
                       provider_response_code, provider_response_message, reason, refund_time, created_at, deleted
                from order_refund where order_id = ? or order_no = ? order by id asc
                """, order.get("id"), order.get("order_no"));
        section("退款记录(order_refund,共 " + rows.size() + " 条)");
        if (rows.isEmpty()) {
            System.out.println("  没有退款记录。");
            return;
        }
        for (Map<String, Object> row : rows) {
            int status = (int) longValue(row.get("refund_status"), -1L);
            String statusText = switch (status) {
                // order_refund 的取值与 benefit_order.refund_status 不同名也不同义:1中 2成功 3失败。
                case 1 -> "退款中";
                case 2 -> "退款成功";
                case 3 -> "退款失败";
                default -> "";
            };
            line("  #" + row.get("id"), text(row.get("refund_no")) + " | refund_status=" + status + " " + statusText
                    + " | 退 " + text(row.get("refund_amount")) + " 分"
                    + " | 已付 " + text(row.get("paid_amount")) + " 分"
                    + " | 可退 " + text(row.get("refundable_amount")) + " 分");
            line("    通道单号   ", text(row.get("provider")) + " | provider_refund_no=" + nullable(row.get("provider_refund_no"))
                    + " | provider_original_no=" + nullable(row.get("provider_original_no"))
                    + " | provider_huifu_id=" + maskId(row.get("provider_huifu_id")));
            line("    通道回执   ", nullable(row.get("provider_response_code")) + " " + nullable(row.get("provider_response_message")));
            line("    时间/原因  ", nullable(row.get("refund_time")) + " | " + nullable(row.get("reason")));
            if (longValue(row.get("deleted"), 0L) == 1L) {
                System.out.println("    ! deleted=1:软删的退款记录。「可退金额」是按未删的行算的,"
                        + "而软删的行仍然占着通道的退款流水号。");
            }
        }
    }
    private static void verifications(JdbcTemplate jdbc, Map<String, Object> order) {
        List<Map<String, Object>> rows = jdbc.queryForList("""
                select id, verify_no, verify_type, verify_amount, subsidy_amount, status, verify_time,
                       revoked_at, revoke_reason
                from verify_record where order_id = ? order by id asc
                """, order.get("id"));
        section("核销记录(verify_record,共 " + rows.size() + " 条;同一订单同时只能有一条 status=1)");
        if (rows.isEmpty()) {
            System.out.println("  没有核销记录:钱还在通道延迟户,商户拿不到。");
            return;
        }
        for (Map<String, Object> row : rows) {
            line("  #" + row.get("id"), text(row.get("verify_no")) + " | " + text(row.get("verify_type"))
                    + " | status=" + text(row.get("status")) + (longValue(row.get("status"), -1L) == 1L ? "(有效)" : "(已撤销)")
                    + " | 核销 " + text(row.get("verify_amount")) + " 分 / 补贴 " + text(row.get("subsidy_amount")) + " 分");
            line("    时间/撤销  ", nullable(row.get("verify_time")) + " | revoked_at "
                    + nullable(row.get("revoked_at")) + " | " + nullable(row.get("revoke_reason")));
        }
    }
    private static void issues(JdbcTemplate jdbc, Map<String, Object> order) {
        StringBuilder sql = new StringBuilder("""
                select id, issue_type, business_no, order_no, expected_status, actual_status, amount,
                       detail_message, status, detected_at, resolved_at, resolved_by
                from payment_reconciliation_issue
                where order_no = ?
                """);
        List<Object> arguments = new ArrayList<>();
        arguments.add(order.get("order_no"));
        List<String> businessNos = new ArrayList<>();
        for (Map<String, Object> payment : jdbc.queryForList(
                "select payment_no from payment_transaction where order_id = ? or order_no = ?",
                order.get("id"), order.get("order_no"))) {
            String value = text(payment.get("payment_no"));
            if (!value.isBlank()) businessNos.add(value);
        }
        for (Map<String, Object> refund : jdbc.queryForList(
                "select refund_no from order_refund where order_id = ? or order_no = ?",
                order.get("id"), order.get("order_no"))) {
            String value = text(refund.get("refund_no"));
            if (!value.isBlank()) businessNos.add(value);
        }
        if (!businessNos.isEmpty()) {
            sql.append(" or business_no in (");
            sql.append("?,".repeat(businessNos.size()));
            sql.setLength(sql.length() - 1);
            sql.append(")");
            arguments.addAll(businessNos);
        }
        sql.append(" order by id desc");
        List<Map<String, Object>> rows = jdbc.queryForList(sql.toString(), arguments.toArray());
        section("对账问题记录(payment_reconciliation_issue,共 " + rows.size() + " 条)");
        if (rows.isEmpty()) {
            System.out.println("  没有:这个订单没有留下「钱与本地状态不符」的问题记录。");
            return;
        }
        for (Map<String, Object> row : rows) {
            line("  #" + row.get("id"), text(row.get("issue_type")) + " | business_no=" + text(row.get("business_no"))
                    + " | status=" + text(row.get("status")));
            line("    期望/实际  ", nullable(row.get("expected_status")) + " -> " + nullable(row.get("actual_status"))
                    + " | " + text(row.get("amount")) + " 分");
            line("    说明       ", nullable(row.get("detail_message")));
            line("    发现/解决  ", nullable(row.get("detected_at")) + " | " + nullable(row.get("resolved_at"))
                    + " / " + nullable(row.get("resolved_by")));
        }
        System.out.println("  status=open 的行是未结问题:它不会自己消失,会一直被对账重新检出,需要人工给结论。");
    }
    // ---------- 参数与输出 ----------
    /** 本探针不问通道,但环境仍然要打招呼:--config 或 --huifu-environment 给了就按它判断。 */
    private static void environmentBanner(String configPath, String environmentOverride) {
        String environment = environmentOverride == null ? "" : environmentOverride.trim();
        if (environment.isBlank() && configPath != null && !configPath.isBlank()) {
            environment = environmentFromConfig(configPath);
        }
        if (environment.isBlank()) {
            System.out.println("! 环境未声明(未给 --config 或 --huifu-environment):本探针只读,但请自行确认下面的"
                    + "数据库目标就是你要排查的那个库,不要在生产库上随手跑。");
            return;
        }
        if (!"prod".equalsIgnoreCase(environment)) {
            System.out.println("环境:" + environment + "(app.payment.huifu.environment)");
            return;
        }
        System.out.println("!! ================================================================================= !!");
        System.out.println("!! app.payment.huifu.environment = prod:目标环境是生产。                          !!");
        System.out.println("!! 本探针只读(整个探针只有 SELECT,连通道都不问),不写任何一行。                  !!");
        System.out.println("!! ================================================================================= !!");
    }
    /** 只从配置文件里取 environment 一个键;私钥、密钥等一律不读进内存。 */
    private static String environmentFromConfig(String path) {
        java.util.Properties source = new java.util.Properties();
        try (java.io.Reader reader = java.nio.file.Files.newBufferedReader(
                java.nio.file.Path.of(path), StandardCharsets.UTF_8)) {
            source.load(reader);
            String value = source.getProperty("app.payment.huifu.environment");
            if (value == null || value.isBlank()) value = source.getProperty("environment");
            return value == null ? "" : value.trim();
        } catch (java.io.IOException exception) {
            System.out.println("! 读不了配置文件 " + path + ":" + exception.getMessage() + "(继续按未声明环境处理)");
            return "";
        }
    }
    /**
     * 汇付号只留前 6 位。通道号是 16 位数字,整串贴出来等于把可枚举的账号交出去;
     * 前 6 位足够看出几条记录是不是同一个商户。
     */
    private static String maskId(Object value) {
        String text = text(value);
        if (text.isBlank()) return "(空)";
        return text.length() <= 6 ? "***" : text.substring(0, 6) + "***";
    }
    /** 只留主机与路径形状:IPv4 留前两段 + x.x,域名原样(它不是 IP),端口与路径照常。 */
    private static String maskHost(String value) {
        if (value == null || value.isBlank()) return "(空)";
        int marker = value.indexOf("//");
        if (marker < 0) return value.trim();
        String prefix = value.substring(0, marker + 2);
        String rest = value.substring(marker + 2);
        int end = rest.length();
        for (int index = 0; index < rest.length(); index++) {
            char current = rest.charAt(index);
            if (current == '/' || current == '?' || current == ';') { end = index; break; }
        }
        String authority = rest.substring(0, end);
        String tail = rest.substring(end);
        Matcher ipv4 = AUTHORITY_IPV4.matcher(authority);
        if (ipv4.matches()) {
            return prefix + ipv4.group(1) + "." + ipv4.group(2) + ".x.x" + (ipv4.group(3) == null ? "" : ipv4.group(3)) + tail;
        }
        return prefix + authority + tail;
    }
    private static String arg(String[] args, String name) {
        for (int index = 0; index < args.length; index++) {
            String current = args[index];
            if (current.equals("--" + name)) return index + 1 < args.length ? args[index + 1] : null;
            if (current.startsWith("--" + name + "=")) return current.substring(name.length() + 3);
        }
        return null;
    }
    private static String required(String[] args, String name) {
        String value = arg(args, name);
        if (value == null || value.isBlank()) {
            System.out.println("缺少必填参数 --" + name);
            usage();
        }
        return value.trim();
    }
    private static void usage() {
        System.out.println("用法:OrderFundBoundaryProbe --db-url=<JDBC URL> --db-user=<用户> --db-password=<口令>"
                + " --order-no=<订单号> [--config=<汇付配置文件>] [--huifu-environment=test|prod]");
        System.exit(2);
    }
    private static void section(String title) {
        System.out.println();
        System.out.println("== " + title + " ==");
    }
    private static void line(String key, String value) {
        System.out.println("  " + key + " : " + value);
    }
    private static String text(Object value) {
        return value == null ? "" : String.valueOf(value).trim();
    }
    private static String nullable(Object value) {
        String text = text(value);
        return text.isBlank() ? "(NULL)" : text;
    }
    private static long longValue(Object value, long fallback) {
        return value instanceof Number number ? number.longValue() : fallback;
    }
}
talent-card-server/scripts/probes/PaymentStateProbe.java
New file
@@ -0,0 +1,503 @@
import com.alibaba.fastjson.JSON;
import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway;
import com.talentcard.server.business.payment.huifu.HuifuPayload;
import com.talentcard.server.business.payment.huifu.HuifuPayload.TransStatus;
import com.talentcard.server.business.payment.huifu.HuifuPaymentCloseQueryCommand;
import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties;
import com.talentcard.server.business.payment.huifu.HuifuPaymentQueryCommand;
import java.io.IOException;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
/**
 * 只读排查探针:一笔支付在本地库里是什么结论,在通道侧又是什么结论,两者差在哪。
 *
 * <p>通道侧的支付状态是权威事实,本地状态只是它的副本;两者不一致时,先看清差在哪里,再决定要不要让
 * 对账去收敛它(后台订单列表的「立即对账」跑的是同一套真实逻辑)。
 */
public class PaymentStateProbe {
    /** 公网 IP 只留主机段前两段:回调/查询地址是公开可达的,整串贴出来等于把入口交出去。 */
    private static final Pattern AUTHORITY_IPV4 = Pattern.compile("^(\\d{1,3})\\.(\\d{1,3})\\.\\d{1,3}\\.\\d{1,3}(:\\d+)?$");
    private static final DateTimeFormatter REQUEST_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
    /**
     * 关单在通道侧是异步的,结论要另问一次关单查询(汇付文档:关单处理中应继续调用关单查询)。
     * 只有这两个本地状态值得问:其余状态下本地已经有结论,问了也多不出信息。
     */
    private static final Set<String> CLOSE_QUERY_STATUSES = Set.of("CLOSING", "CANCEL_UNCONFIRMED");
    /** 通道「交易不存在」:它不认识这个请求流水号,也就不可能为它收过钱。 */
    private static final String TRADE_MISSING = "23000001";
    /**
     * 回答什么问题:给定订单号或支付流水号,本地 {@code payment_transaction} 是什么状态,通道侧这笔
     * 交易现在是什么状态,两者差在哪(含金额、收款子商户、关单结论)。
     *
     * <p>什么时候用它:用户说「钱扣了订单没变」「取消了还能付」「一直显示支付处理中」,而回调审计表
     * ({@code payment_callback_event})看不出结论时——它把本地与通道摆在一起,差异一眼可见。
     *
     * <p>它不改任何状态:只做 SELECT,通道侧只调查单与关单查询,不发起支付、不关单、不退款、不交易确认、
     * 不下发业务开通、不写库。查询本身会落在通道的调用记录里,是唯一的「痕迹」。
     *
     * <p>参数格式(全部从命令行传,缺参数直接打用法退出,不用任何默认值瞎猜):
     * <pre>
     * --db-url=jdbc:mysql://&lt;host&gt;:&lt;port&gt;/&lt;db&gt;?useUnicode=true&amp;characterEncoding=utf8&amp;serverTimezone=Asia/Shanghai&amp;useSSL=false
     * --db-user=&lt;用户&gt; --db-password=&lt;口令&gt;
     * --order-no=&lt;订单号&gt;          与 --payment-no 二选一
     * --payment-no=&lt;支付流水号&gt;
     * [--config=&lt;汇付配置文件路径&gt;]  不给就只查本地、不问通道
     * [--huifu-environment=test|prod] 覆盖配置文件里的 environment
     * </pre>
     */
    public static void main(String[] args) {
        String dbUrl = required(args, "db-url");
        String dbUser = required(args, "db-user");
        String dbPassword = required(args, "db-password");
        String orderNo = arg(args, "order-no");
        String paymentNo = arg(args, "payment-no");
        if (blank(orderNo) == blank(paymentNo)) {
            System.out.println("必须且只能给一个:--order-no=<订单号> 或 --payment-no=<支付流水号>。");
            usage();
        }
        String configPath = arg(args, "config");
        HuifuPaymentProperties properties = blank(configPath) ? null : loadProperties(configPath, arg(args, "huifu-environment"));
        warnIfProduction(properties, "app.payment.huifu.environment");
        System.out.println("只读探针:只做 SELECT 与通道的查询类接口(查单 / 关单查询)。不发起支付、不关单、不退款、"
                + "不交易确认、不下发业务开通、不改任何数据库行。");
        System.out.println("数据库目标:" + maskHost(dbUrl));
        if (!dbUrl.toLowerCase(Locale.ROOT).contains("servertimezone")) {
            System.out.println("! 提示:--db-url 上没有 serverTimezone。org_req_date 由 created_at 推出来,"
                    + "时区解释不同会整体偏一天,通道那边只会回「交易不存在」——那会看起来像「钱丢了」。");
        }
        JdbcTemplate jdbc = connect(dbUrl, dbUser, dbPassword);
        String key = !blank(orderNo) ? orderNo.trim() : paymentNo.trim();
        List<Map<String, Object>> rows = jdbc.queryForList("""
                select id, payment_no, order_no, order_id, merchant_id, amount, status,
                       funds_confirmation_status, idempotency_key, attempt_no, provider_transaction_no,
                       provider_huifu_id, close_attempts, last_reconciled_at, provider_response_code,
                       provider_response_message, created_at, paid_at, closed_at
                from payment_transaction
                where payment_no = ? or order_no = ?
                order by id asc
                """, key, key);
        if (rows.isEmpty()) {
            System.out.println();
            System.out.println("本地没有匹配的支付流水:payment_no 或 order_no = " + key);
            System.out.println("如果确认订单号没错,先查订单本身(OrderFundBoundaryProbe),再看是不是根本没有支付尝试。");
            return;
        }
        for (Map<String, Object> row : rows) {
            localState(row, key, jdbc);
            askProvider(row, properties);
            System.out.println();
        }
        siblingAttempts(jdbc, rows);
    }
    /** 打印一行流水的本地口径。 */
    private static void localState(Map<String, Object> row, String key, JdbcTemplate jdbc) {
        String paymentNo = text(row.get("payment_no"));
        section("本地状态 / payment_transaction#" + row.get("id") + "(" + (key.equals(paymentNo) ? "按支付流水号匹配" : "按订单号匹配") + ")");
        line("payment_no            ", paymentNo);
        line("order_no              ", text(row.get("order_no")));
        line("status                ", text(row.get("status")));
        line("funds_confirmation_status", nullable(row.get("funds_confirmation_status")));
        line("amount                ", text(row.get("amount")) + " 分");
        line("idempotency_key       ", text(row.get("idempotency_key")));
        line("attempt_no            ", text(row.get("attempt_no")));
        line("provider_transaction_no", nullable(row.get("provider_transaction_no")));
        line("provider_huifu_id     ", maskId(row.get("provider_huifu_id")));
        line("close_attempts        ", text(row.get("close_attempts")));
        line("last_reconciled_at    ", nullable(row.get("last_reconciled_at")));
        line("created_at            ", nullable(row.get("created_at")));
        line("paid_at / closed_at   ", nullable(row.get("paid_at")) + " / " + nullable(row.get("closed_at")));
        line("provider_response     ", nullable(row.get("provider_response_code")) + " " + nullable(row.get("provider_response_message")));
        // 没有 funds_revert_status 这一列:回退期间 funds_confirmation_status 用 'reverting' 占位(V147),
        // 回退的细节留在 funds_revert_* 上。所以「回退到哪一步了」要看上面那个状态 + 下面这些证据列。
        System.out.println();
        System.out.println("  资金确认/回退的证据列(funds_confirmation_status = reverting 表示回退在途,"
                + "没有独立的回退状态列):");
        Map<String, Object> funds = fundsEvidence(jdbc, row);
        if (funds == null) {
            System.out.println("  本库缺少 funds_* 证据列,跳过(开发库结构不由迁移维护,见 docs/agents/testing.md)。");
        } else {
            for (Map.Entry<String, Object> entry : funds.entrySet()) {
                line("  " + entry.getKey(), nullable(entry.getValue()));
            }
        }
        if ("SUCCEEDED".equals(text(row.get("status"))) && row.get("funds_confirmation_status") == null) {
            System.out.println("  说明:funds_confirmation_status 为 NULL 表示这笔支付没走延迟入账(支付即核销的到店消费),"
                    + "钱不需要确认。");
        }
    }
    /**
     * 资金确认与回退的现场证据,单独一条查询取。
     *
     * <p>不并进主查询:这些列是 V139 / V147 加上去的,而开发库的结构不由迁移维护、缺列只能人工补,
     * 并进去会让「缺一列」变成「整条主结论都打不出来」。缺列时这里返回 null,主结论照常。
     */
    private static Map<String, Object> fundsEvidence(JdbcTemplate jdbc, Map<String, Object> row) {
        try {
            return new LinkedHashMap<>(jdbc.queryForMap("""
                    select funds_confirm_no, funds_confirm_request_date, funds_confirm_code, funds_confirm_message,
                           funds_confirmed_amount, funds_confirmed_at,
                           funds_revert_no, funds_revert_request_date, funds_revert_code, funds_revert_message,
                           funds_reverted_amount, funds_reverted_at
                    from payment_transaction where id = ?
                    """, row.get("id")));
        } catch (org.springframework.dao.DataAccessException exception) {
            return null;
        }
    }
    /** 问通道,并把差异逐条说出来。 */
    private static void askProvider(Map<String, Object> row, HuifuPaymentProperties properties) {
        section("通道真实状态");
        if (properties == null) {
            System.out.println("未给 --config,跳过通道查询。要问通道请附上配置文件(见 scripts/probes/README.md)。");
            return;
        }
        String paymentNo = text(row.get("payment_no"));
        String providerTransactionNo = text(row.get("provider_transaction_no"));
        String requestDate = requestDate(row.get("created_at"));
        String huifuId = text(row.get("provider_huifu_id"));
        if (blank(huifuId)) {
            // V131 之前的支付由平台商户收款,本地没记收款子商户。这里按 HuifuRefundService.refundHuifuId
            // 的同一条回退取平台商户号去问,否则这些历史流水永远问不出结论。
            huifuId = text(properties.getMerchantId());
            System.out.println("本地 provider_huifu_id 为空(V131 之前的支付,由平台商户收款),"
                    + "按 HuifuRefundService 的同一条回退用配置里的平台商户号去问。");
        }
        if (blank(huifuId)) {
            System.out.println("连平台商户号也没有(配置里 merchant-id 为空),无法问通道:通道查询必须先说明问哪家商户。");
            return;
        }
        line("发起请求  ", "paymentNo=" + paymentNo + " orgReqDate=" + requestDate
                + " orgHfSeqId=" + (blank(providerTransactionNo) ? "(无)" : providerTransactionNo)
                + " huifuId=" + maskId(huifuId));
        HuifuMiniProgramPaymentGateway gateway = new HuifuMiniProgramPaymentGateway(properties);
        Map<String, Object> response;
        try {
            response = gateway.queryMiniProgramPayment(
                    new HuifuPaymentQueryCommand(paymentNo, huifuId, providerTransactionNo, requestDate));
        } catch (RuntimeException exception) {
            System.out.println("通道查询失败:" + exception.getClass().getSimpleName() + " " + text(exception.getMessage()));
            System.out.println("读法:通道没给出结论时不要往本地补结论——这是「不知道」,不是「没收钱」。");
            return;
        }
        Map<String, Object> data = HuifuPayload.businessData(response);
        String code = HuifuPayload.code(data);
        String message = HuifuPayload.message(data);
        String transStatus = HuifuPayload.value(data, "trans_stat", "transStat");
        // 支付查询回执里通道全局流水号是 org_hf_seq_id;hf_seq_id 只出现在异步通知里。
        String providerNo = HuifuPayload.value(data, "org_hf_seq_id", "orgHfSeqId", "hf_seq_id", "hfSeqId");
        Long providerAmount = HuifuPayload.amountInCent(HuifuPayload.value(data, "trans_amt", "transAmt"));
        String providerHuifuId = HuifuPayload.value(data, "huifu_id", "huifuId");
        TransStatus status = HuifuPayload.status(transStatus);
        boolean paid = HuifuPayload.paid(transStatus, code);
        line("resp_code / desc", code + " " + message);
        line("trans_stat    ", transStatus + "(" + status + ")");
        line("org_hf_seq_id ", blank(providerNo) ? "(未给)" : providerNo);
        line("huifu_id      ", maskId(providerHuifuId));
        line("trans_amt     ", providerAmount == null ? "(未给)" : providerAmount + " 分");
        System.out.println("原始回执(已脱敏,openid / 私钥类字段一律不打印):" + sanitized(response));
        section("差异");
        String local = text(row.get("status"));
        long localAmount = row.get("amount") instanceof Number number ? number.longValue() : -1L;
        boolean found = false;
        if (paid && !"SUCCEEDED".equals(local)) {
            found = true;
            System.out.println("* 通道:已收款(" + code + ")。本地:" + local + "。钱在通道,本地没落成已支付——"
                    + "回调丢了或没被处理。下一步:后台订单列表点「立即对账」,它跑的是真实对账逻辑;"
                    + "同时看 payment_callback_event 里这笔的 process_status。");
        }
        if (!found && "SUCCEEDED".equals(local) && !paid && status != TransStatus.UNKNOWN) {
            found = true;
            System.out.println("* 本地:已支付。通道:" + (status == TransStatus.FAILED ? "失败/已关闭" : "处理中") + "(" + code + ")。"
                    + "本地认了钱、通道说没有,先怀疑查错了商户或查错了日期(huifuId 与 orgReqDate)。");
        }
        if (providerAmount != null && localAmount >= 0 && providerAmount != localAmount) {
            found = true;
            System.out.println("* 金额不一致:本地 " + localAmount + " 分,通道 " + providerAmount + " 分。"
                    + "本地结算按本地金额走,这种差异必须有结论(对账会把金额不符记成 PAYMENT_AMOUNT_MISMATCH)。");
        }
        if (!blank(providerHuifuId) && !blank(text(row.get("provider_huifu_id")))
                && !providerHuifuId.equals(text(row.get("provider_huifu_id")))) {
            found = true;
            System.out.println("* 收款子商户漂移:本地记 " + maskId(row.get("provider_huifu_id"))
                    + ",通道说是 " + maskId(providerHuifuId) + "。退款要按实际收款的商户发起,"
                    + "这种单子必须先查清是哪个商户收的钱。");
        }
        if (TRADE_MISSING.equals(code)) {
            found = true;
            System.out.println("* 通道不认识这个请求流水号(" + TRADE_MISSING + "):它不可能为这笔收过钱。"
                    + "若本地还是 PREPAY_CREATED,这笔凭证在通道侧等于不存在,可以直接按未支付处理。");
        }
        if (status == TransStatus.PENDING || status == TransStatus.UNKNOWN) {
            found = true;
            System.out.println("* 通道还没有结论(" + transStatus + "):只能等或让对账继续问,"
                    + "不要在这种状态下把本地改成已支付或已关闭。");
        }
        if (!found) {
            System.out.println("* 本地与通道一致:" + local + " <-> trans_stat=" + transStatus + "。");
        }
        if (CLOSE_QUERY_STATUSES.contains(local)) {
            askCloseQuery(gateway, row, huifuId, providerTransactionNo, requestDate);
        }
    }
    /** 本地是 CLOSING / CANCEL_UNCONFIRMED 时,关单结论只能问关单查询。 */
    private static void askCloseQuery(HuifuMiniProgramPaymentGateway gateway, Map<String, Object> row,
                                      String huifuId, String providerTransactionNo, String requestDate) {
        section("关单查询(本地状态 " + text(row.get("status")) + ",说明关单还在等结论)");
        Map<String, Object> response;
        try {
            response = gateway.closeQueryMiniProgramPayment(new HuifuPaymentCloseQueryCommand(
                    "PQ" + System.currentTimeMillis(), text(row.get("payment_no")), huifuId, providerTransactionNo, requestDate));
        } catch (RuntimeException exception) {
            System.out.println("关单查询失败:" + exception.getClass().getSimpleName() + " " + text(exception.getMessage()));
            return;
        }
        Map<String, Object> data = HuifuPayload.businessData(response);
        String code = HuifuPayload.code(data);
        String transStatus = HuifuPayload.value(data, "trans_stat", "transStat");
        String originalStatus = HuifuPayload.value(data, "org_trans_stat", "orgTransStat");
        line("resp_code / desc  ", code + " " + HuifuPayload.message(data));
        line("trans_stat        ", transStatus);
        line("org_trans_stat    ", originalStatus);
        if ("S".equalsIgnoreCase(transStatus) && HuifuPayload.successCode(code)) {
            System.out.println("读法:关单已成功,本地可以落成 CLOSED(对账下一轮会这么做)。");
        } else if ("S".equalsIgnoreCase(originalStatus)) {
            System.out.println("读法:原交易已经成功——钱可能已经收下。此时不能当「未支付」处理,"
                    + "先按上面那次查单确认金额。");
        } else if ("P".equalsIgnoreCase(transStatus) || "00000100".equals(code)) {
            System.out.println("读法:关单还在处理中。这是个可等待的中间态,不是终点。");
        } else {
            System.out.println("读法:关单被拒。未满一分钟不允许关单、原交易不支持关单都会走到这里;"
                    + "代码 23000000 / 10000016 表示原交易已终结(那张预支付单不可能再被支付)。");
        }
    }
    /** 同一订单下的其它支付尝试:一个订单可能被重复发起支付,「钱收了两笔」这个问题只能这样看出来。 */
    private static void siblingAttempts(JdbcTemplate jdbc, List<Map<String, Object>> rows) {
        String orderNo = text(rows.get(0).get("order_no"));
        if (blank(orderNo)) return;
        List<Map<String, Object>> siblings = jdbc.queryForList("""
                select id, payment_no, status, amount, idempotency_key, attempt_no, created_at
                from payment_transaction where order_no = ? order by id asc
                """, orderNo);
        if (siblings.size() <= 1) return;
        section("同订单的全部支付尝试(order_no=" + orderNo + ",共 " + siblings.size() + " 条)");
        System.out.println("  幂等键不同就会各自在通道建一张预支付单——重复扣款要看的就是这里。");
        for (Map<String, Object> sibling : siblings) {
            line("  " + sibling.get("id"), sibling.get("payment_no") + " | " + sibling.get("status")
                    + " | " + sibling.get("amount") + " 分 | key=" + sibling.get("idempotency_key")
                    + " | attempt_no=" + sibling.get("attempt_no") + " | " + sibling.get("created_at"));
        }
    }
    // ---------- 参数、配置与输出 ----------
    /** 读汇付配置。<b>不读 application.yml</b>:那里面的私钥与密钥只在本探针内部使用,从不打印。 */
    private static HuifuPaymentProperties loadProperties(String path, String environmentOverride) {
        Properties source = new Properties();
        try (Reader reader = Files.newBufferedReader(Path.of(path), StandardCharsets.UTF_8)) {
            source.load(reader);
        } catch (IOException exception) {
            System.out.println("读不了配置文件 " + path + ":" + exception.getMessage());
            usage();
        }
        HuifuPaymentProperties properties = new HuifuPaymentProperties();
        // 网关的 ensureConfigured 要求 enabled=true,否则查询会被自己拦住。配置没写这个键时按 true 处理,
        // 免得「探针被自己的开关拦住」变成一个查不出原因的空输出。
        String enabled = value(source, "enabled");
        properties.setEnabled(enabled.isBlank() || Boolean.parseBoolean(enabled));
        properties.setEnvironment(firstNonBlank(environmentOverride, firstNonBlank(value(source, "environment"), "test")));
        apply(value(source, "system-id"), properties::setSystemId);
        apply(value(source, "product-id"), properties::setProductId);
        apply(value(source, "merchant-id"), properties::setMerchantId);
        apply(value(source, "mini-program-app-id"), properties::setMiniProgramAppId);
        apply(value(source, "private-key"), properties::setPrivateKey);
        apply(value(source, "platform-public-key"), properties::setPlatformPublicKey);
        apply(value(source, "notify-url"), properties::setNotifyUrl);
        apply(value(source, "merchant-business-notify-url"), properties::setMerchantBusinessNotifyUrl);
        apply(value(source, "merchant-onboarding-notify-url"), properties::setMerchantOnboardingNotifyUrl);
        apply(value(source, "merchant-wechat-config-notify-url"), properties::setMerchantWechatConfigNotifyUrl);
        apply(value(source, "merchant-business-open-notify-url"), properties::setMerchantBusinessOpenNotifyUrl);
        return properties;
    }
    /** 键名兼容 {@code system-id} 与 {@code app.payment.huifu.system-id} 两种写法(后者照抄部署配置)。 */
    private static String value(Properties source, String key) {
        return firstNonBlank(source.getProperty("app.payment.huifu." + key), source.getProperty(key));
    }
    private static String firstNonBlank(String first, String second) {
        if (first != null && !first.isBlank()) return first.trim();
        return second == null ? "" : second.trim();
    }
    private static void apply(String value, java.util.function.Consumer<String> setter) {
        if (value != null && !value.isBlank()) setter.accept(value.trim());
    }
    /** 生产环境只提示、不拦截:本探针没有写路径,环境不改变它能不能跑,只改变你该不该紧张。 */
    private static void warnIfProduction(HuifuPaymentProperties properties, String key) {
        if (properties == null || !"prod".equalsIgnoreCase(properties.getEnvironment())) return;
        System.out.println("!! ================================================================================= !!");
        System.out.println("!! " + key + " = prod:目标是生产环境。                                      !!");
        System.out.println("!! 本探针只读(只 SELECT + 通道查询类接口),不发起支付、不关单、不退款、不确认资金。 !!");
        System.out.println("!! 但查询会留在通道的调用记录里,排查请按生产操作对待。                            !!");
        System.out.println("!! ================================================================================= !!");
    }
    private static JdbcTemplate connect(String url, String user, String password) {
        // 不写死 driver-class-name:DriverManager 会按 SPI 找到 mysql-connector-j,
        // 写死类名只会在驱动换 artifactId 时变成一个假故障。
        return new JdbcTemplate(new DriverManagerDataSource(url, user, password));
    }
    /**
     * 商户号 / 汇付号只留前 6 位。通道号是 16 位数字,整串贴出来等于把可枚举的账号交出去;
     * 前 6 位足够在几条记录之间区分是同一个商户。
     */
    private static String maskId(Object value) {
        String text = text(value);
        if (text.isBlank()) return "(空)";
        return text.length() <= 6 ? "***" : text.substring(0, 6) + "***";
    }
    /** 只留主机与路径形状:IPv4 留前两段 + x.x,域名原样(它不是 IP),端口与路径照常——排查要看的正是这两段。 */
    private static String maskHost(String value) {
        if (value == null || value.isBlank()) return "(空)";
        int marker = value.indexOf("//");
        if (marker < 0) return value.trim();
        String prefix = value.substring(0, marker + 2);
        String rest = value.substring(marker + 2);
        int end = rest.length();
        for (int index = 0; index < rest.length(); index++) {
            char current = rest.charAt(index);
            if (current == '/' || current == '?' || current == ';') { end = index; break; }
        }
        String authority = rest.substring(0, end);
        String tail = rest.substring(end);
        Matcher ipv4 = AUTHORITY_IPV4.matcher(authority);
        if (ipv4.matches()) {
            return prefix + ipv4.group(1) + "." + ipv4.group(2) + ".x.x" + (ipv4.group(3) == null ? "" : ipv4.group(3)) + tail;
        }
        return prefix + authority + tail;
    }
    /** 回执里凡是商户号、openid、签名材料一律替换掉再打印。 */
    private static String sanitized(Map<String, Object> payload) {
        try {
            return JSON.toJSONString(sanitize(payload));
        } catch (RuntimeException exception) {
            return "(回执无法序列化:" + exception.getClass().getSimpleName() + ")";
        }
    }
    private static Object sanitize(Object value) {
        if (value instanceof Map<?, ?> source) {
            Map<String, Object> result = new LinkedHashMap<>();
            for (Map.Entry<?, ?> entry : source.entrySet()) {
                String key = String.valueOf(entry.getKey());
                String normalized = key.toLowerCase(Locale.ROOT);
                if (normalized.contains("huifu_id") || normalized.contains("huifuid")) {
                    result.put(key, maskId(entry.getValue()));
                } else if (normalized.contains("openid") || normalized.contains("sign") || normalized.contains("private")
                        || normalized.contains("secret") || normalized.contains("card")) {
                    result.put(key, "***");
                } else {
                    result.put(key, sanitize(entry.getValue()));
                }
            }
            return result;
        }
        if (value instanceof List<?> source) return source.stream().map(PaymentStateProbe::sanitize).toList();
        if (value instanceof String text && (text.startsWith("{") || text.startsWith("["))) {
            try {
                return sanitize(JSON.parse(text));
            } catch (RuntimeException ignored) {
                return text;
            }
        }
        return value;
    }
    /**
     * 通道查询用 {@code org_req_date} 定位原交易,取值就是发起支付那天。
     *
     * <p>这里必须同时认 {@code java.sql.Timestamp}:本探针用裸 JdbcTemplate 取行,datetime 列回来的是
     * Timestamp,不是应用里 {@code DatabaseRows} 归一化后的 LocalDateTime。
     */
    private static String requestDate(Object value) {
        if (value instanceof java.sql.Timestamp timestamp) return REQUEST_DATE.format(timestamp.toLocalDateTime());
        if (value instanceof LocalDateTime dateTime) return REQUEST_DATE.format(dateTime);
        if (value instanceof LocalDate date) return REQUEST_DATE.format(date);
        return REQUEST_DATE.format(LocalDateTime.now());
    }
    private static String arg(String[] args, String name) {
        for (int index = 0; index < args.length; index++) {
            String current = args[index];
            if (current.equals("--" + name)) return index + 1 < args.length ? args[index + 1] : null;
            if (current.startsWith("--" + name + "=")) return current.substring(name.length() + 3);
        }
        return null;
    }
    private static String required(String[] args, String name) {
        String value = arg(args, name);
        if (value == null || value.isBlank()) {
            System.out.println("缺少必填参数 --" + name);
            usage();
        }
        return value.trim();
    }
    private static void usage() {
        System.out.println("用法:PaymentStateProbe --db-url=<JDBC URL> --db-user=<用户> --db-password=<口令>"
                + " (--order-no=<订单号> | --payment-no=<支付流水号>) [--config=<汇付配置文件>] [--huifu-environment=test|prod]");
        System.exit(2);
    }
    private static void section(String title) {
        System.out.println();
        System.out.println("== " + title + " ==");
    }
    private static void line(String key, String value) {
        System.out.println("  " + key + " : " + value);
    }
    private static String text(Object value) {
        return value == null ? "" : String.valueOf(value).trim();
    }
    private static String nullable(Object value) {
        String text = text(value);
        return text.isBlank() ? "(NULL)" : text;
    }
    private static boolean blank(String value) {
        return value == null || value.isBlank();
    }
}
talent-card-server/scripts/probes/README.md
New file
@@ -0,0 +1,182 @@
# 汇付链路只读排查探针
这一批探针是为 **2026-09-11 ~ 2026-09-18 完成的汇付天下/斗拱支付链路交接**准备的:遇到线上问题时,
不用回来找人,用它直接回答「这笔钱 / 这个商户在通道侧到底是什么状态」。
## 硬约束(先读这一节)
**1. 只读。** 允许调用的只有两类:
- 本地数据库:**只 `SELECT`**。
- 通道:**查单**(`v3/trade/payment/scanpay/query`)、**关单查询**(`v2/trade/payment/scanpay/closequery`)、
  **商户信息查询**(`v2/merchant/basicdata/query`)、**结算记录查询**(`v2/merchant/basicdata/settlement/query`)。
**严禁**:发起支付、关单、退款、交易确认、交易确认退款、下发业务开通、上传图片、以及任何 `INSERT/UPDATE/DELETE`
(包括「只是顺手刷新一下能力快照」——那是写库,属于 `MerchantPaymentReadinessService.refresh` 的活,
要走后台的「立即核实」)。
这不是口头约定:这五个探针的代码里**没有任何一条写路径**。`MerchantCapabilityProbe` 特意绕开了
`refresh` / `capability` / `refreshQuietly` / `merchantVerify` 这四个会写快照的方法,只用只读的
`readiness` 与 `channelCapabilityNormal`。
**2. 生产保护。** 探针启动时读汇付配置里的 `app.payment.huifu.environment`(等价于运行时的 profile 判断);
判断为 `prod` 时打印显著警告。它**只是提示、不是闸门**——因为没有写路径,环境不改变探针能不能跑,
只改变你该不该紧张。用 `--huifu-environment=prod` 可以直接声明环境(不必给整个配置文件)。
**3. 输出脱敏。** 以下内容**绝对不打印**:
| 不打印 | 本探针的处理 |
| --- | --- |
| 私钥、密钥 | 从不读进内存。只输出「已配置 / 未配置」(布尔) |
| 完整商户号、完整汇付号 | 只留**前 6 位** + `***` |
| 完整银行卡号 | 从不选取;结算记录里的卡号类字段整个替换成 `***`;本地只印 `card_masked` 与尾号 4 位 |
| 完整公网 IP | 只留主机段**前两段** + `x.x`,端口与路径照常打印(排查要看的正是这两段) |
| 用户 openid、签名材料 | 回执打印前统一替换成 `***` |
**4. 参数从命令行传。** 五个探针与五个运行脚本里**没有任何写死的目标**:不给 `--db-url` 就不知道连哪个库,
不给 `--order-no` 就不知道查哪一单,缺参数一律打印用法并以退出码 2 退出,绝不拿默认值瞎猜。
> 反面教材就在本仓库:`.scratch/` 下早先会话留下的一次性探针(`*Probe*.java`、`Inspect*.java`)大多把
> JDBC URL 写死成 `127.0.0.1:8134/talent_card`。**那个地址上放的是 2026-09-16 把本机库并入服务器库之前
> 的旧副本,不是开发库**(见 `docs/agents/testing.md`)。本目录的探针不接受任何默认地址。
## 目录内容
| 文件 | 作用 |
| --- | --- |
| `PaymentStateProbe.java` / `run-PaymentStateProbe.ps1` | 一笔支付:本地状态 vs 通道真实状态,指出差异 |
| `MerchantCapabilityProbe.java` / `run-MerchantCapabilityProbe.ps1` | 一个商户:本地能力快照 vs 通道真值,并算「能不能收款」 |
| `OrderFundBoundaryProbe.java` / `run-OrderFundBoundaryProbe.ps1` | 一笔订单:资金边界三列 + 支付/退款/核销/对账问题记录 |
| `SettlementRecordProbe.java` / `run-SettlementRecordProbe.ps1` | 一个商户按日期区间:通道结算记录 |
| `CallbackUrlAuditProbe.java` / `run-CallbackUrlAuditProbe.ps1` | 五个回调地址:哪个空、哪个是推导出来的 |
| `huifu-probe.properties.example` | 需要连通道时用的配置文件模板 |
## 运行机制
与 `docs/audits/2026-09-10/run-probe.ps1` 完全一致,没有另创一套:
1. `mvn -q -DskipTests compile dependency:build-classpath -Dmdep.outputFile=target/probes/classpath.txt -Dmdep.includeScope=test`
   拿到 `target/classes` 与依赖 classpath;
2. `javac -encoding UTF-8 -cp "target/classes;<cp>" -d target/probes <探针>.java`
   ——探针不在 `src/main/java` 下,所以**不进 Maven 编译**,靠脚本单独编译;
3. `java -cp "target/probes;target/classes;<cp>" <探针> <参数>`
两条与仓库环境有关的注意:
- **同一 checkout 一次只跑一个 Maven 构建**。脚本每次都会起一个构建;已经构建过、不想到处撞车时加
  `-SkipBuild`,它会复用 `target/probes/classpath.txt`。
- 建议用 `pwsh`(PowerShell 7)跑,理由见 `docs/agents/testing.md`。
## 需要连通道时:配置文件
连通道的探针要先有 `system-id` / `product-id` / 私钥 / 平台公钥。**故意不读 `application.yml`**:那里面的
私钥与密钥是凭据,探针「读进来只为用、绝不打印」,就没有必要把它读进去。改成读一个由你自备的 properties 文件:
```powershell
Copy-Item scripts\probes\huifu-probe.properties.example scripts\probes\huifu-probe.properties
# 填好后:huifu-probe.properties 是凭据文件,不要提交进仓库
```
键名与 `application.yml` 里 `app.payment.huifu.*` 一致,前缀可写可不写
(`system-id` 与 `app.payment.huifu.system-id` 都认)。`enabled` 不给时按 `true` 处理,
否则网关自己的 `ensureConfigured` 会把查询拦住。
## 五个探针怎么用
### PaymentStateProbe —— 这笔钱通道收了吗
```powershell
.\scripts\probes\run-PaymentStateProbe.ps1 -PaymentNo HF20260917001 `
    -DbUrl $env:PROBE_DB_URL -DbUser root -DbPassword 123456 -Config .\scripts\probes\huifu-probe.properties
```
打印 `payment_transaction` 的 `status` / `funds_confirmation_status` / 金额 / 幂等键 / `attempt_no` /
`provider_huifu_id` / `close_attempts` / `last_reconciled_at` 与 `funds_*` 证据列,再问一次通道
(`org_req_date` 取自 `created_at`,`org_hf_seq_id` 取自 `provider_transaction_no`),然后逐条列出差异:
通道已收款而本地没落成已支付、金额不一致、收款子商户漂移、通道说「交易不存在」(`23000001`)等。
本地状态是 `CLOSING` / `CANCEL_UNCONFIRMED` 时,会再问一次**关单查询**(关单在通道侧是异步的,
结论只能靠这条查询)。
**注意**:本地 `provider_huifu_id` 为空(V131 之前的支付,由平台商户收款)时,按
`HuifuRefundService.refundHuifuId` 的同一条回退,用配置里的平台商户号去问通道——否则那批历史流水永远问不出结论。
### MerchantCapabilityProbe —— 这家商户能不能收款
```powershell
.\scripts\probes\run-MerchantCapabilityProbe.ps1 -ApplicationId 128 `
    -DbUrl $env:PROBE_DB_URL -DbUser root -DbPassword 123456 -Config .\scripts\probes\huifu-probe.properties
```
定位方式三选一:`-ApplicationId` / `-HuifuId` / `-MerchantId`(可加 `-StoreId`)。
打印本地快照(`merchant_stat` / `auto_settle_status` / `cash_switch_status` / `cash_type` /
`capability_verified_at` / `capability_response_*`)与通道 `v2/merchant/basicdata/query` 的真值,
逐列指出差异,再用 `MerchantPaymentReadinessService.readiness(merchantId, storeId)` 给出**能不能收款**
的结论与原因——那句文案与用户下单被拒时的文案同源。
两个容易看错的点,探针会直接说明:
- `capability_verified_at` 为空 = 本地**从没核实过**通道状态。在要求通道确认的部署上,闸门会直接拒
  「商户收款能力待确认」,与通道那边实际是否正常**无关**——这就是「通道正常却收不了款」最常见的原因。
- `auto_settle_status` **已经不是**收款闸门(未核销的钱由延迟户兜住),只作参考,不要拿它下结论。
不给 `-Config` 时也会给结论,但判定会退化成「跳过通道确认」那一档(`merchant_capability_required=false`),
**与线上默认不一致**,探针会明确警告。
### OrderFundBoundaryProbe —— 这笔订单的钱走到哪一步了
```powershell
.\scripts\probes\run-OrderFundBoundaryProbe.ps1 -OrderNo ORD20260917001 `
    -DbUrl $env:PROBE_DB_URL -DbUser root -DbPassword 123456
```
打印 `benefit_order` 的资金边界三列(`order_status` / `verify_status` / `refund_status`)、创建时固定的收款方
(`collect_onboarding_id` / `collect_store_id` / `collect_huifu_id`)、全部支付流水、全部退款记录、核销记录,
以及 `payment_reconciliation_issue` 里按订单号与流水号/退款号命中的问题记录。
它**不问通道**,因此不需要 `-Config`(给了只用来看 `environment` 打生产警告)。三列的口径:
钱进商户基本户要 `order_status=1` 且 `verify_status=1` 且 `refund_status=0`(资金确认重试就是这么筛的);
退款在途(`refund_status=1`)会让整单退出可核销与可确认范围。
### SettlementRecordProbe —— 通道到底结了多少
```powershell
.\scripts\probes\run-SettlementRecordProbe.ps1 -HuifuId 6666000241335656 `
    -BeginDate 20260911 -EndDate 20260918 -Config .\scripts\probes\huifu-probe.properties
```
直查 `/v2/merchant/basicdata/settlement/query`。**pageSize 被钉在 1..50**:通道对 pageSize 的硬上限就是 50,
超了直接返回 `00000003`「…最大值为50」而且**一条记录都拿不到**(后台此前默认传 100,于是「结算记录」页
永远空白,而通道里其实有流水)。要看得更多只能**缩小日期窗口**,通道不返回总页数。
### CallbackUrlAuditProbe —— 五个回调地址自检
```powershell
.\scripts\probes\run-CallbackUrlAuditProbe.ps1 -Config .\scripts\probes\huifu-probe.properties
```
逐个打印五个回调地址(脱敏后)与状态:**显式配置 / 由 `notify-url` 推导 / 为空**,并明确列出
「为空的地址(回调未启用)」。取值规则直接调用应用里那份 `HuifuCallbackUrlAudit`(同一个类、同一批 getter),
所以不会与启动日志里的结论不一致。
一句最容易被误解的话:**某个回调地址留空不等于「不回调」**。四个非支付回调在配置为空时,会由 `notify-url`
推导出同主机同前缀的地址(`HuifuPaymentProperties#callbackUrl`);真正的「回调未启用」只有一种,
那就是这个地址算出来是空串。探针还会标两件事:路径与 `PaymentCallbackController` 暴露的五个端点对不上
(会 404)、以及某个地址的主机与 `notify-url` 不同(那台机器也必须能收到并处理这个回调)。
## 已知边界与坑
- **验证到什么程度**(2026-09-18):五个探针都用 `javac` 对 `target/classes` + 依赖 jar 编译通过;
  四个读库的探针在**测试库**(见 `docs/agents/testing.md`)上实跑过——本地快照、支付/退款/核销/问题记录、
  能力判定、缺参数退出码,都是真实执行的结果。**通道查询没有实跑过**(没有提供凭据,也不该在排查之外
  凭空打通道):`PaymentStateProbe` 的查单与关单查询、`MerchantCapabilityProbe` 的商户信息查询、
  `SettlementRecordProbe` 的结算记录查询都只做到「编译通过 + 请求参数装配正确」。第一次用可以拿一个
  已知答案的单子先试一次,对不上再回来看这一节。
- **开发库的结构不由迁移维护**(`flyway.enabled=false`)。缺列的表现是探针里那条查询直接报错,
  而不是少打印几个字段。看到 `Unknown column` 就说明该库还缺某条迁移的等价 DDL,
  按 `docs/agents/testing.md` 与 `scripts/dev-schema-*.sql` 补。`PaymentStateProbe` 的 `funds_*` 证据列
  单独一条查询取并容忍失败,就是为了不让缺列连累主结论。
- **口令会出现在进程列表里**:`-DbPassword` 是命令行参数。共享机器上排查时自己注意。
- `--db-url` 建议带上 `serverTimezone=Asia/Shanghai`(应用就是这么配的)。`PaymentStateProbe` 靠
  `created_at` 推 `org_req_date`,时区解释不同会整体偏一天,通道只会回「交易不存在」——那会看起来像「钱丢了」。
- 探针对通道只做**单次**查询,没有分页遍历:结算记录要翻页请缩小日期窗口;支付与商户信息本来就是单条查询。
- 本目录的探针**没有配套的自动化测试**(它们是排查工具,不是生产代码路径)。
talent-card-server/scripts/probes/SettlementRecordProbe.java
New file
@@ -0,0 +1,241 @@
import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties;
import com.talentcard.server.business.payment.huifu.HuifuSettlementRecordGateway;
import java.io.IOException;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Properties;
/**
 * 只读排查探针:问通道「钱到底结了多少到这家商户的银行卡」,按日期区间直查结算记录。
 *
 * <p>出款改由斗拱按结算周期自动完成之后,平台侧不再有提现记录,「每天结了多少」只有通道知道。
 */
public class SettlementRecordProbe {
    /**
     * 通道对 {@code pageSize} 的硬上限,与 {@code HuifuSettlementRecordGateway.PROVIDER_MAX_PAGE_SIZE} 同一个值。
     *
     * <p>实测超过 50 时接口直接返回 {@code 00000003}
     * 「基础参数校验失败:【pageSize-分页条数】不能为空,且最小值为0,最大值为50」,<b>一条记录都拿不到</b>。
     * 后台此前默认传 100,于是「结算记录」页永远空白,而通道里其实有流水。本探针同样必须钉住这个上限,
     * 不是照原样透传调用方给的值。
     */
    private static final int PROVIDER_MAX_PAGE_SIZE = 50;
    private static final DateTimeFormatter REQUEST_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
    /**
     * 回答什么问题:给定汇付号与日期区间,通道 {@code /v2/merchant/basicdata/settlement/query} 里这家商户
     * 结了多少笔、多少钱、结算到哪张卡(卡号一律不打印)。
     *
     * <p>什么时候用它:商户说「钱没到账」、或者后台「结算记录」页空白时——先确认通道侧到底有没有结算流水,
     * 再区分是「还没到结算周期」还是「通道里根本没有这笔钱的流水」(后者要去查支付与资金确认)。
     *
     * <p>它不改任何状态:只调用通道的结算记录查询接口,不发起支付、不关单、不退款、不交易确认、
     * 不下发业务开通、连本地数据库都不连。
     *
     * <p>参数格式(全部从命令行传,缺参数直接打用法退出,不用任何默认值瞎猜):
     * <pre>
     * --config=&lt;汇付配置文件路径&gt;        必填:通道查询要先有 system-id / product-id / 私钥 / 平台公钥
     * --huifu-id=&lt;斗拱商户号&gt;           必填
     * --begin-date=yyyyMMdd --end-date=yyyyMMdd   必填
     * [--page-size=&lt;1..50&gt;]            默认 50(通道上限);越界值会被收敛,不会被发到通道
     * [--huifu-environment=test|prod]
     * </pre>
     */
    public static void main(String[] args) {
        String configPath = required(args, "config");
        String huifuId = required(args, "huifu-id");
        String beginDate = required(args, "begin-date");
        String endDate = required(args, "end-date");
        int requested = intValue(arg(args, "page-size"), PROVIDER_MAX_PAGE_SIZE);
        int pageSize = Math.min(Math.max(requested, 1), PROVIDER_MAX_PAGE_SIZE);
        requireBasicDate("--begin-date", beginDate);
        requireBasicDate("--end-date", endDate);
        HuifuPaymentProperties properties = loadProperties(configPath, arg(args, "huifu-environment"));
        warnIfProduction(properties);
        System.out.println("只读探针:只调用通道的结算记录查询接口。不发起支付、不关单、不退款、不交易确认、"
                + "不下发业务开通、不连本地数据库、不改任何状态。");
        System.out.println();
        System.out.println("== 请求 ==");
        line("huifuId ", maskId(huifuId));
        line("区间     ", beginDate + " ~ " + endDate);
        line("pageSize", pageSize + (pageSize == requested ? "" : "(你要的 " + requested + " 已收敛到 1..50)"));
        System.out.println("  说明:通道对 pageSize 的硬上限就是 50,超了直接回 00000003 且一条都不返回,"
                + "所以这里不会原样透传。要看得更多只能缩小日期窗口,通道不返回总页数。");
        HuifuSettlementRecordGateway.SettlementRecords records;
        try {
            records = new HuifuSettlementRecordGateway(properties).query(huifuId, beginDate, endDate, pageSize);
        } catch (RuntimeException exception) {
            System.out.println();
            System.out.println("== 结果 ==");
            System.out.println("查询失败:" + exception.getClass().getSimpleName() + " " + text(exception.getMessage()));
            System.out.println("常见读法:00000003 是参数校验失败(先看参数,不一定是 pageSize);"
                    + "通道说商户不存在就核对 --huifu-id 与 app.payment.huifu.environment 是否配套"
                    + "(测试环境的产品号问不到生产商户)。");
            System.out.println("查不通不代表没有结算流水:钱的问题仍以支付与资金确认那两处为准。");
            return;
        }
        System.out.println();
        System.out.println("== 结果 ==");
        line("succeeded   ", String.valueOf(records.succeeded()));
        line("resp_code   ", text(records.code()));
        line("message     ", text(records.message()));
        line("result_count", String.valueOf(records.total()));
        if (!records.succeeded()) {
            System.out.println("通道没有给出成功回执。它可能什么都没查,也可能查了但整体被拒——"
                    + "不要在这种时候下「没有结算流水」的结论。");
        }
        System.out.println();
        System.out.println("结算记录(trans_log_result_list,已对商户号/卡号脱敏):");
        System.out.println(sanitizeRaw(records.rawRecords()));
        System.out.println();
        System.out.println("读法:");
        System.out.println("  * 通道里没有记录而商户说没到账 → 先看订单的 verify_status 与 funds_confirmation_status:"
                + "未核销的钱还在通道延迟户,不会进结算。");
        System.out.println("  * result_count 打满 pageSize 时,这个窗口里很可能还有下一页——缩小日期窗口再查一次。");
        System.out.println("  * 结算按商户(huifuId)维度、跨门店汇总;对不上时核对收款的子商户号"
                + "(benefit_order.collect_huifu_id / payment_transaction.provider_huifu_id)。");
    }
    // ---------- 参数、配置与输出 ----------
    /** 读汇付配置。<b>不读 application.yml</b>:私钥与密钥只在本探针内部使用,从不打印。 */
    private static HuifuPaymentProperties loadProperties(String path, String environmentOverride) {
        Properties source = new Properties();
        try (Reader reader = Files.newBufferedReader(Path.of(path), StandardCharsets.UTF_8)) {
            source.load(reader);
        } catch (IOException exception) {
            System.out.println("读不了配置文件 " + path + ":" + exception.getMessage());
            usage();
        }
        HuifuPaymentProperties properties = new HuifuPaymentProperties();
        String enabled = value(source, "enabled");
        properties.setEnabled(enabled.isBlank() || Boolean.parseBoolean(enabled));
        properties.setEnvironment(firstNonBlank(environmentOverride, firstNonBlank(value(source, "environment"), "test")));
        apply(value(source, "system-id"), properties::setSystemId);
        apply(value(source, "product-id"), properties::setProductId);
        apply(value(source, "merchant-id"), properties::setMerchantId);
        apply(value(source, "private-key"), properties::setPrivateKey);
        apply(value(source, "platform-public-key"), properties::setPlatformPublicKey);
        apply(value(source, "notify-url"), properties::setNotifyUrl);
        System.out.println("配置状态(只印有无,私钥与公钥从不打印):system-id=" + configured(properties.getSystemId())
                + " product-id=" + configured(properties.getProductId())
                + " private-key=" + configured(properties.getPrivateKey())
                + " platform-public-key=" + configured(properties.getPlatformPublicKey())
                + " enabled=" + properties.isEnabled()
                + " environment=" + properties.getEnvironment());
        return properties;
    }
    private static String configured(String value) {
        return value == null || value.isBlank() ? "未配置" : "已配置";
    }
    /** 键名兼容 {@code system-id} 与 {@code app.payment.huifu.system-id} 两种写法(后者照抄部署配置)。 */
    private static String value(Properties source, String key) {
        return firstNonBlank(source.getProperty("app.payment.huifu." + key), source.getProperty(key));
    }
    private static String firstNonBlank(String first, String second) {
        if (first != null && !first.isBlank()) return first.trim();
        return second == null ? "" : second.trim();
    }
    private static void apply(String value, java.util.function.Consumer<String> setter) {
        if (value != null && !value.isBlank()) setter.accept(value.trim());
    }
    /** 生产环境只提示、不拦截:本探针没有写路径,环境不改变它能不能跑,只改变你该不该紧张。 */
    private static void warnIfProduction(HuifuPaymentProperties properties) {
        if (!"prod".equalsIgnoreCase(properties.getEnvironment())) return;
        System.out.println("!! ================================================================================= !!");
        System.out.println("!! app.payment.huifu.environment = prod:目标是生产环境。                          !!");
        System.out.println("!! 本探针只读(只查结算记录),不发起任何资金操作。                                !!");
        System.out.println("!! 但查询会留在通道的调用记录里,排查请按生产操作对待。                            !!");
        System.out.println("!! ================================================================================= !!");
    }
    /** 日期必须是通道要的 {@code yyyyMMdd};写成 2026-09-17 通道会当参数错误,而且不容易看出是自己写错了。 */
    private static void requireBasicDate(String name, String value) {
        if (!value.matches("\\d{8}")) {
            System.out.println(name + " 必须是 yyyyMMdd(例如 20260917),收到:" + value);
            usage();
        }
        try {
            LocalDate.parse(value, REQUEST_DATE);
        } catch (RuntimeException exception) {
            System.out.println(name + " 不是一个真实日期:" + value);
            usage();
        }
    }
    /**
     * 只做两件事,都是为了「不把凭据印出来」:商户号留前 6 位;卡号类字段整个替换掉
     * (结算记录里带结算卡信息,卡号绝不该出现在排查输出里)。
     */
    private static String sanitizeRaw(String raw) {
        if (raw == null || raw.isBlank()) return "(通道没有返回记录列表)";
        String masked = raw.replaceAll("(?i)(huifu_?id[\"']?\\s*[:=]\\s*[\"']?)(\\d{6})\\d+", "$1$2***");
        return masked.replaceAll("(?i)((?:card_?no|cardno|settle_?card|bank_?card)[\"']?\\s*[:=]\\s*[\"']?)([^,\\]}\\s\"']+)", "$1***");
    }
    /**
     * 商户号只留前 6 位。通道号是 16 位数字,整串贴出来等于把可枚举的账号交出去;
     * 前 6 位足够看出几条记录是不是同一个商户。
     */
    private static String maskId(String value) {
        String text = text(value);
        if (text.isBlank()) return "(空)";
        return text.length() <= 6 ? "***" : text.substring(0, 6) + "***";
    }
    private static String arg(String[] args, String name) {
        for (int index = 0; index < args.length; index++) {
            String current = args[index];
            if (current.equals("--" + name)) return index + 1 < args.length ? args[index + 1] : null;
            if (current.startsWith("--" + name + "=")) return current.substring(name.length() + 3);
        }
        return null;
    }
    private static int intValue(String value, int fallback) {
        if (value == null || value.isBlank()) return fallback;
        try {
            return Integer.parseInt(value.trim());
        } catch (NumberFormatException exception) {
            System.out.println("--page-size 必须是整数,收到:" + value);
            usage();
            return fallback;
        }
    }
    private static String required(String[] args, String name) {
        String value = arg(args, name);
        if (value == null || value.isBlank()) {
            System.out.println("缺少必填参数 --" + name);
            usage();
        }
        return value.trim();
    }
    private static void usage() {
        System.out.println("用法:SettlementRecordProbe --config=<汇付配置文件> --huifu-id=<斗拱商户号>"
                + " --begin-date=yyyyMMdd --end-date=yyyyMMdd [--page-size=<1..50>] [--huifu-environment=test|prod]");
        System.exit(2);
    }
    private static void line(String key, String value) {
        System.out.println("  " + key + " : " + value);
    }
    private static String text(Object value) {
        return value == null ? "" : String.valueOf(value).trim();
    }
}
talent-card-server/scripts/probes/huifu-probe.properties.example
New file
@@ -0,0 +1,36 @@
# 汇付排查探针的配置文件模板。
#
# 用法:复制成 huifu-probe.properties 后填上真实值(文件放在 scripts/probes/ 下即可,也可以放别处,
# 用 --config=<路径> / -Config <路径> 指过去)。**填好的那份是凭据文件,不要提交进仓库。**
#
# 键名与 src/main/resources/application.yml 里 app.payment.huifu.* 一致,前缀可写可不写
# (system-id 与 app.payment.huifu.system-id 都认)。探针保证不打印私钥/平台公钥/完整商户号。
#
# 只有「本地数据库 + 通道查询」两类探针需要这个文件:
#   PaymentStateProbe / MerchantCapabilityProbe / SettlementRecordProbe / CallbackUrlAuditProbe
# OrderFundBoundaryProbe 不问通道,只在想打生产警告时才需要它。
#
# private-key / platform-public-key 直接抄 application.yml 里的单行值(base64,不带 PEM 头尾)。
# 空值 = 不写这一行;探针打印时只会说「已配置 / 未配置」。
environment=test
system-id=
product-id=
merchant-id=
mini-program-app-id=
private-key=
platform-public-key=
# 五个回调地址。留空的那几项会由 notify-url 推导出同主机同前缀的地址,而不是「不会回调」
# (见 HuifuPaymentProperties#callbackUrl)。想看清通道侧实际注册了哪个地址,就逐项显式写上。
notify-url=http://<回调主机>:<端口>/api/payments/huifu/notify
merchant-business-notify-url=
merchant-onboarding-notify-url=
merchant-wechat-config-notify-url=
merchant-business-open-notify-url=
# 不写下面两行时的默认值就是这里写出来的值,写出来只是让「探针按什么口径跑」显式可见。
# enabled 留 false 会让网关的 ensureConfigured 直接拒掉通道查询。
enabled=true
merchant-capability-required=true
talent-card-server/scripts/probes/run-CallbackUrlAuditProbe.ps1
New file
@@ -0,0 +1,41 @@
# CallbackUrlAuditProbe 的运行脚本(只读排查探针)。
#
# 机制与 docs/audits/2026-09-10/run-probe.ps1 一致:先用 Maven 拿到 target/classes 与依赖 classpath,
# 再单独 javac 这个探针(它不在 src/main/java 下,所以不进 Maven 编译),最后 java 跑它。
#
# 本探针不连数据库、不连通道,只读一个配置文件——所以除了 -Config 以外什么都不用给。
#
# 用法示例:
#   .\run-CallbackUrlAuditProbe.ps1 -Config .\huifu-probe.properties
param(
    [Parameter(Mandatory = $true)][string] $Config,
    [ValidateSet('test', 'prod')][string] $HuifuEnvironment,
    [switch] $SkipBuild
)
$ErrorActionPreference = 'Stop'
$probeArgs = @("--config=$Config")
if ($HuifuEnvironment) { $probeArgs += "--huifu-environment=$HuifuEnvironment" }
$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
Push-Location $serverRoot
try {
    $classpathFile = 'target/probes/classpath.txt'
    # -SkipBuild 是给「同一 checkout 一次只跑一个 Maven 构建」这条硬约束准备的:已经构建过就别再起一个。
    if ($SkipBuild -and (Test-Path $classpathFile)) {
        Write-Host "跳过 Maven,复用 $classpathFile" -ForegroundColor Cyan
    } else {
        $maven = (Get-Command mvn -ErrorAction SilentlyContinue).Source
        if (-not $maven) { $maven = Join-Path $serverRoot 'mvnw.cmd' }
        & $maven -q -DskipTests compile dependency:build-classpath "-Dmdep.outputFile=$classpathFile" '-Dmdep.includeScope=test'
        if ($LASTEXITCODE -ne 0) { throw 'Maven preparation failed' }
    }
    $probeCp = (Get-Content $classpathFile -Raw).Trim()
    & javac -encoding UTF-8 -cp "target/classes;$probeCp" -d target/probes (Join-Path $PSScriptRoot 'CallbackUrlAuditProbe.java')
    if ($LASTEXITCODE -ne 0) { throw 'Probe compilation failed' }
    & java -cp "target/probes;target/classes;$probeCp" CallbackUrlAuditProbe @probeArgs
    if ($LASTEXITCODE -ne 0) { throw "Probe exited with $LASTEXITCODE" }
} finally {
    Pop-Location
}
talent-card-server/scripts/probes/run-MerchantCapabilityProbe.ps1
New file
@@ -0,0 +1,57 @@
# MerchantCapabilityProbe 的运行脚本(只读排查探针)。
#
# 机制与 docs/audits/2026-09-10/run-probe.ps1 一致:先用 Maven 拿到 target/classes 与依赖 classpath,
# 再单独 javac 这个探针(它不在 src/main/java 下,所以不进 Maven 编译),最后 java 跑它。
#
# 所有参数都从命令行传,脚本里没有默认值:探针的 --db-url 指向哪个库,排查的就是哪个库。
# 端口 8134 上放的是 2026-09-16 合并进服务器库之前的本机旧副本,不是开发库(见 docs/agents/testing.md)。
#
# 用法示例:
#   .\run-MerchantCapabilityProbe.ps1 -ApplicationId 128 -DbUrl $env:PROBE_DB_URL -DbUser root -DbPassword 123456 -Config .\huifu-probe.properties
param(
    [string] $ApplicationId,
    [string] $HuifuId,
    [string] $MerchantId,
    [string] $StoreId,
    [Parameter(Mandatory = $true)][string] $DbUrl,
    [Parameter(Mandatory = $true)][string] $DbUser,
    [Parameter(Mandatory = $true)][string] $DbPassword,
    [string] $Config,
    [ValidateSet('test', 'prod')][string] $HuifuEnvironment,
    [switch] $SkipBuild
)
$ErrorActionPreference = 'Stop'
$given = @($ApplicationId, $HuifuId, $MerchantId) | Where-Object { $_ }
if ($given.Count -eq 0) { throw '必须给一个定位方式:-ApplicationId、-HuifuId 或 -MerchantId。' }
$probeArgs = @("--db-url=$DbUrl", "--db-user=$DbUser", "--db-password=$DbPassword")
if ($ApplicationId) { $probeArgs += "--application-id=$ApplicationId" }
if ($HuifuId) { $probeArgs += "--huifu-id=$HuifuId" }
if ($MerchantId) { $probeArgs += "--merchant-id=$MerchantId" }
if ($StoreId) { $probeArgs += "--store-id=$StoreId" }
if ($Config) { $probeArgs += "--config=$Config" }
if ($HuifuEnvironment) { $probeArgs += "--huifu-environment=$HuifuEnvironment" }
$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
Push-Location $serverRoot
try {
    $classpathFile = 'target/probes/classpath.txt'
    # -SkipBuild 是给「同一 checkout 一次只跑一个 Maven 构建」这条硬约束准备的:已经构建过就别再起一个。
    if ($SkipBuild -and (Test-Path $classpathFile)) {
        Write-Host "跳过 Maven,复用 $classpathFile" -ForegroundColor Cyan
    } else {
        $maven = (Get-Command mvn -ErrorAction SilentlyContinue).Source
        if (-not $maven) { $maven = Join-Path $serverRoot 'mvnw.cmd' }
        & $maven -q -DskipTests compile dependency:build-classpath "-Dmdep.outputFile=$classpathFile" '-Dmdep.includeScope=test'
        if ($LASTEXITCODE -ne 0) { throw 'Maven preparation failed' }
    }
    $probeCp = (Get-Content $classpathFile -Raw).Trim()
    & javac -encoding UTF-8 -cp "target/classes;$probeCp" -d target/probes (Join-Path $PSScriptRoot 'MerchantCapabilityProbe.java')
    if ($LASTEXITCODE -ne 0) { throw 'Probe compilation failed' }
    & java -cp "target/probes;target/classes;$probeCp" MerchantCapabilityProbe @probeArgs
    if ($LASTEXITCODE -ne 0) { throw "Probe exited with $LASTEXITCODE" }
} finally {
    Pop-Location
}
talent-card-server/scripts/probes/run-OrderFundBoundaryProbe.ps1
New file
@@ -0,0 +1,47 @@
# OrderFundBoundaryProbe 的运行脚本(只读排查探针)。
#
# 机制与 docs/audits/2026-09-10/run-probe.ps1 一致:先用 Maven 拿到 target/classes 与依赖 classpath,
# 再单独 javac 这个探针(它不在 src/main/java 下,所以不进 Maven 编译),最后 java 跑它。
#
# 所有参数都从命令行传,脚本里没有默认值:探针的 --db-url 指向哪个库,排查的就是哪个库。
# 端口 8134 上放的是 2026-09-16 合并进服务器库之前的本机旧副本,不是开发库(见 docs/agents/testing.md)。
#
# 用法示例:
#   .\run-OrderFundBoundaryProbe.ps1 -OrderNo ORD20260917001 -DbUrl $env:PROBE_DB_URL -DbUser root -DbPassword 123456
param(
    [Parameter(Mandatory = $true)][string] $OrderNo,
    [Parameter(Mandatory = $true)][string] $DbUrl,
    [Parameter(Mandatory = $true)][string] $DbUser,
    [Parameter(Mandatory = $true)][string] $DbPassword,
    [string] $Config,
    [ValidateSet('test', 'prod')][string] $HuifuEnvironment,
    [switch] $SkipBuild
)
$ErrorActionPreference = 'Stop'
$probeArgs = @("--db-url=$DbUrl", "--db-user=$DbUser", "--db-password=$DbPassword", "--order-no=$OrderNo")
if ($Config) { $probeArgs += "--config=$Config" }
if ($HuifuEnvironment) { $probeArgs += "--huifu-environment=$HuifuEnvironment" }
$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
Push-Location $serverRoot
try {
    $classpathFile = 'target/probes/classpath.txt'
    # -SkipBuild 是给「同一 checkout 一次只跑一个 Maven 构建」这条硬约束准备的:已经构建过就别再起一个。
    if ($SkipBuild -and (Test-Path $classpathFile)) {
        Write-Host "跳过 Maven,复用 $classpathFile" -ForegroundColor Cyan
    } else {
        $maven = (Get-Command mvn -ErrorAction SilentlyContinue).Source
        if (-not $maven) { $maven = Join-Path $serverRoot 'mvnw.cmd' }
        & $maven -q -DskipTests compile dependency:build-classpath "-Dmdep.outputFile=$classpathFile" '-Dmdep.includeScope=test'
        if ($LASTEXITCODE -ne 0) { throw 'Maven preparation failed' }
    }
    $probeCp = (Get-Content $classpathFile -Raw).Trim()
    & javac -encoding UTF-8 -cp "target/classes;$probeCp" -d target/probes (Join-Path $PSScriptRoot 'OrderFundBoundaryProbe.java')
    if ($LASTEXITCODE -ne 0) { throw 'Probe compilation failed' }
    & java -cp "target/probes;target/classes;$probeCp" OrderFundBoundaryProbe @probeArgs
    if ($LASTEXITCODE -ne 0) { throw "Probe exited with $LASTEXITCODE" }
} finally {
    Pop-Location
}
talent-card-server/scripts/probes/run-PaymentStateProbe.ps1
New file
@@ -0,0 +1,54 @@
# PaymentStateProbe 的运行脚本(只读排查探针)。
#
# 机制与 docs/audits/2026-09-10/run-probe.ps1 一致:先用 Maven 拿到 target/classes 与依赖 classpath,
# 再单独 javac 这个探针(它不在 src/main/java 下,所以不进 Maven 编译),最后 java 跑它。
#
# 所有参数都从命令行传,脚本里没有默认值:探针的 --db-url 指向哪个库,排查的就是哪个库。
# 端口 8134 上放的是 2026-09-16 合并进服务器库之前的本机旧副本,不是开发库(见 docs/agents/testing.md),
# 别照那个地址跑。
#
# 用法示例:
#   .\run-PaymentStateProbe.ps1 -PaymentNo=HF20260917001 -DbUrl $env:PROBE_DB_URL -DbUser root -DbPassword 123456 -Config .\huifu-probe.properties
param(
    [string] $OrderNo,
    [string] $PaymentNo,
    [Parameter(Mandatory = $true)][string] $DbUrl,
    [Parameter(Mandatory = $true)][string] $DbUser,
    [Parameter(Mandatory = $true)][string] $DbPassword,
    [string] $Config,
    [ValidateSet('test', 'prod')][string] $HuifuEnvironment,
    [switch] $SkipBuild
)
$ErrorActionPreference = 'Stop'
if (-not $OrderNo -and -not $PaymentNo) { throw '必须给一个:-OrderNo=<订单号> 或 -PaymentNo=<支付流水号>。' }
if ($OrderNo -and $PaymentNo) { throw '只能给一个:-OrderNo 或 -PaymentNo。' }
$probeArgs = @("--db-url=$DbUrl", "--db-user=$DbUser", "--db-password=$DbPassword")
if ($OrderNo) { $probeArgs += "--order-no=$OrderNo" }
if ($PaymentNo) { $probeArgs += "--payment-no=$PaymentNo" }
if ($Config) { $probeArgs += "--config=$Config" }
if ($HuifuEnvironment) { $probeArgs += "--huifu-environment=$HuifuEnvironment" }
$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
Push-Location $serverRoot
try {
    $classpathFile = 'target/probes/classpath.txt'
    # -SkipBuild 是给「同一 checkout 一次只跑一个 Maven 构建」这条硬约束准备的:已经构建过就别再起一个。
    if ($SkipBuild -and (Test-Path $classpathFile)) {
        Write-Host "跳过 Maven,复用 $classpathFile" -ForegroundColor Cyan
    } else {
        $maven = (Get-Command mvn -ErrorAction SilentlyContinue).Source
        if (-not $maven) { $maven = Join-Path $serverRoot 'mvnw.cmd' }
        & $maven -q -DskipTests compile dependency:build-classpath "-Dmdep.outputFile=$classpathFile" '-Dmdep.includeScope=test'
        if ($LASTEXITCODE -ne 0) { throw 'Maven preparation failed' }
    }
    $probeCp = (Get-Content $classpathFile -Raw).Trim()
    & javac -encoding UTF-8 -cp "target/classes;$probeCp" -d target/probes (Join-Path $PSScriptRoot 'PaymentStateProbe.java')
    if ($LASTEXITCODE -ne 0) { throw 'Probe compilation failed' }
    & java -cp "target/probes;target/classes;$probeCp" PaymentStateProbe @probeArgs
    if ($LASTEXITCODE -ne 0) { throw "Probe exited with $LASTEXITCODE" }
} finally {
    Pop-Location
}
talent-card-server/scripts/probes/run-SettlementRecordProbe.ps1
New file
@@ -0,0 +1,47 @@
# SettlementRecordProbe 的运行脚本(只读排查探针)。
#
# 机制与 docs/audits/2026-09-10/run-probe.ps1 一致:先用 Maven 拿到 target/classes 与依赖 classpath,
# 再单独 javac 这个探针(它不在 src/main/java 下,所以不进 Maven 编译),最后 java 跑它。
#
# 所有参数都从命令行传,脚本里没有默认值。本探针不连本地数据库,只查通道,所以没有 -Db* 参数。
#
# 用法示例:
#   .\run-SettlementRecordProbe.ps1 -HuifuId 6666000241335656 -BeginDate 20260911 -EndDate 20260918 -Config .\huifu-probe.properties
param(
    [Parameter(Mandatory = $true)][string] $HuifuId,
    [Parameter(Mandatory = $true)][string] $BeginDate,
    [Parameter(Mandatory = $true)][string] $EndDate,
    [Parameter(Mandatory = $true)][string] $Config,
    [ValidateRange(1, 50)][int] $PageSize = 0,
    [ValidateSet('test', 'prod')][string] $HuifuEnvironment,
    [switch] $SkipBuild
)
$ErrorActionPreference = 'Stop'
$probeArgs = @("--config=$Config", "--huifu-id=$HuifuId", "--begin-date=$BeginDate", "--end-date=$EndDate")
# pageSize 的上限由探针自己钉住(通道超过 50 会回 00000003 且一条不返回),这里只是不传非法值。
if ($PageSize -gt 0) { $probeArgs += "--page-size=$PageSize" }
if ($HuifuEnvironment) { $probeArgs += "--huifu-environment=$HuifuEnvironment" }
$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
Push-Location $serverRoot
try {
    $classpathFile = 'target/probes/classpath.txt'
    # -SkipBuild 是给「同一 checkout 一次只跑一个 Maven 构建」这条硬约束准备的:已经构建过就别再起一个。
    if ($SkipBuild -and (Test-Path $classpathFile)) {
        Write-Host "跳过 Maven,复用 $classpathFile" -ForegroundColor Cyan
    } else {
        $maven = (Get-Command mvn -ErrorAction SilentlyContinue).Source
        if (-not $maven) { $maven = Join-Path $serverRoot 'mvnw.cmd' }
        & $maven -q -DskipTests compile dependency:build-classpath "-Dmdep.outputFile=$classpathFile" '-Dmdep.includeScope=test'
        if ($LASTEXITCODE -ne 0) { throw 'Maven preparation failed' }
    }
    $probeCp = (Get-Content $classpathFile -Raw).Trim()
    & javac -encoding UTF-8 -cp "target/classes;$probeCp" -d target/probes (Join-Path $PSScriptRoot 'SettlementRecordProbe.java')
    if ($LASTEXITCODE -ne 0) { throw 'Probe compilation failed' }
    & java -cp "target/probes;target/classes;$probeCp" SettlementRecordProbe @probeArgs
    if ($LASTEXITCODE -ne 0) { throw "Probe exited with $LASTEXITCODE" }
} finally {
    Pop-Location
}