From db704fbed062d5700a37b7858231bf7d0958e38d Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期五, 18 九月 2026 14:05:09 +0800
Subject: [PATCH] chore(payment): 新增 5 个只读排查探针与运行脚本
---
talent-card-server/scripts/probes/run-OrderFundBoundaryProbe.ps1 | 47 +
talent-card-server/scripts/probes/OrderFundBoundaryProbe.java | 368 +++++++++
talent-card-server/scripts/probes/huifu-probe.properties.example | 36
talent-card-server/scripts/probes/SettlementRecordProbe.java | 241 ++++++
talent-card-server/scripts/probes/CallbackUrlAuditProbe.java | 254 ++++++
talent-card-server/scripts/probes/PaymentStateProbe.java | 503 +++++++++++++
talent-card-server/scripts/probes/run-CallbackUrlAuditProbe.ps1 | 41 +
talent-card-server/scripts/probes/README.md | 182 ++++
talent-card-server/scripts/probes/run-SettlementRecordProbe.ps1 | 47 +
talent-card-server/scripts/probes/MerchantCapabilityProbe.java | 404 ++++++++++
talent-card-server/scripts/probes/run-MerchantCapabilityProbe.ps1 | 57 +
talent-card-server/scripts/probes/run-PaymentStateProbe.ps1 | 54 +
12 files changed, 2,234 insertions(+), 0 deletions(-)
diff --git a/talent-card-server/scripts/probes/CallbackUrlAuditProbe.java b/talent-card-server/scripts/probes/CallbackUrlAuditProbe.java
new file mode 100644
index 0000000..a705934
--- /dev/null
+++ b/talent-card-server/scripts/probes/CallbackUrlAuditProbe.java
@@ -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=<汇付配置文件路径> 必填
+ * 配置文件里的键(前缀 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);
+ }
+}
diff --git a/talent-card-server/scripts/probes/MerchantCapabilityProbe.java b/talent-card-server/scripts/probes/MerchantCapabilityProbe.java
new file mode 100644
index 0000000..f47c11b
--- /dev/null
+++ b/talent-card-server/scripts/probes/MerchantCapabilityProbe.java
@@ -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=<JDBC URL> --db-user=<用户> --db-password=<口令>
+ * 三者之一:--application-id=<进件ID> | --huifu-id=<斗拱商户号> | --merchant-id=<本地商户ID>
+ * [--store-id=<门店ID>] 判定「能不能收款」时按这个门店问;默认用命中进件自己的 store_id
+ * [--config=<汇付配置文件路径>] 不给就只查本地,且判定会退化成「跳过通道确认」那一档
+ * [--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();
+ }
+}
diff --git a/talent-card-server/scripts/probes/OrderFundBoundaryProbe.java b/talent-card-server/scripts/probes/OrderFundBoundaryProbe.java
new file mode 100644
index 0000000..84e60f6
--- /dev/null
+++ b/talent-card-server/scripts/probes/OrderFundBoundaryProbe.java
@@ -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=<JDBC URL> --db-user=<用户> --db-password=<口令>
+ * --order-no=<订单号>
+ * [--config=<汇付配置文件路径>] 只为读 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;
+ }
+}
diff --git a/talent-card-server/scripts/probes/PaymentStateProbe.java b/talent-card-server/scripts/probes/PaymentStateProbe.java
new file mode 100644
index 0000000..65f2947
--- /dev/null
+++ b/talent-card-server/scripts/probes/PaymentStateProbe.java
@@ -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://<host>:<port>/<db>?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
+ * --db-user=<用户> --db-password=<口令>
+ * --order-no=<订单号> 与 --payment-no 二选一
+ * --payment-no=<支付流水号>
+ * [--config=<汇付配置文件路径>] 不给就只查本地、不问通道
+ * [--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();
+ }
+}
diff --git a/talent-card-server/scripts/probes/README.md b/talent-card-server/scripts/probes/README.md
new file mode 100644
index 0000000..fa7aa38
--- /dev/null
+++ b/talent-card-server/scripts/probes/README.md
@@ -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`,时区解释不同会整体偏一天,通道只会回「交易不存在」——那会看起来像「钱丢了」。
+- 探针对通道只做**单次**查询,没有分页遍历:结算记录要翻页请缩小日期窗口;支付与商户信息本来就是单条查询。
+- 本目录的探针**没有配套的自动化测试**(它们是排查工具,不是生产代码路径)。
diff --git a/talent-card-server/scripts/probes/SettlementRecordProbe.java b/talent-card-server/scripts/probes/SettlementRecordProbe.java
new file mode 100644
index 0000000..ca1157a
--- /dev/null
+++ b/talent-card-server/scripts/probes/SettlementRecordProbe.java
@@ -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=<汇付配置文件路径> 必填:通道查询要先有 system-id / product-id / 私钥 / 平台公钥
+ * --huifu-id=<斗拱商户号> 必填
+ * --begin-date=yyyyMMdd --end-date=yyyyMMdd 必填
+ * [--page-size=<1..50>] 默认 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();
+ }
+}
diff --git a/talent-card-server/scripts/probes/huifu-probe.properties.example b/talent-card-server/scripts/probes/huifu-probe.properties.example
new file mode 100644
index 0000000..acbc935
--- /dev/null
+++ b/talent-card-server/scripts/probes/huifu-probe.properties.example
@@ -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
diff --git a/talent-card-server/scripts/probes/run-CallbackUrlAuditProbe.ps1 b/talent-card-server/scripts/probes/run-CallbackUrlAuditProbe.ps1
new file mode 100644
index 0000000..abcfd60
--- /dev/null
+++ b/talent-card-server/scripts/probes/run-CallbackUrlAuditProbe.ps1
@@ -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
+}
diff --git a/talent-card-server/scripts/probes/run-MerchantCapabilityProbe.ps1 b/talent-card-server/scripts/probes/run-MerchantCapabilityProbe.ps1
new file mode 100644
index 0000000..2cb0a3a
--- /dev/null
+++ b/talent-card-server/scripts/probes/run-MerchantCapabilityProbe.ps1
@@ -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
+}
diff --git a/talent-card-server/scripts/probes/run-OrderFundBoundaryProbe.ps1 b/talent-card-server/scripts/probes/run-OrderFundBoundaryProbe.ps1
new file mode 100644
index 0000000..e959cef
--- /dev/null
+++ b/talent-card-server/scripts/probes/run-OrderFundBoundaryProbe.ps1
@@ -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
+}
diff --git a/talent-card-server/scripts/probes/run-PaymentStateProbe.ps1 b/talent-card-server/scripts/probes/run-PaymentStateProbe.ps1
new file mode 100644
index 0000000..6a5bd62
--- /dev/null
+++ b/talent-card-server/scripts/probes/run-PaymentStateProbe.ps1
@@ -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
+}
diff --git a/talent-card-server/scripts/probes/run-SettlementRecordProbe.ps1 b/talent-card-server/scripts/probes/run-SettlementRecordProbe.ps1
new file mode 100644
index 0000000..2eef8dd
--- /dev/null
+++ b/talent-card-server/scripts/probes/run-SettlementRecordProbe.ps1
@@ -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
+}
--
Gitblit v1.7.1