chore(payment): 新增 5 个只读排查探针与运行脚本
接手的人遇到「这笔钱/这个商户在通道侧到底是什么状态」时不用回来找人:
PaymentStateProbe 比对某笔支付在本地与通道的真实状态,指出差异
MerchantCapabilityProbe 比对某商户的本地能力快照与通道 basicdata 真实值,并给出能否收款
OrderFundBoundaryProbe 打印某订单的资金边界、资金确认状态与相关差错记录
SettlementRecordProbe 直查通道结算记录(钉住 pageSize<=50)
CallbackUrlAuditProbe 回调地址自检,单跑版
严格只读:只允许 SELECT 与通道查询类接口,不提供任何写路径。输出脱敏,不打印私钥、
完整商户号或完整公网 IP。参数从命令行传入、不带默认地址——仓库里已有一批把 URL 写死
127.0.0.1:8134 的一次性探针,那个端口上是合并前的旧副本库,不是开发库。
运行方式沿用 docs/audits/2026-09-10/run-probe.ps1 的既有三步(取 classpath → javac → java),
额外提供 -SkipBuild,因为本仓库规定同一 checkout 一次只跑一个 Maven 构建。
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| New file |
| | |
| | | # 汇付链路只读排查探针 |
| | | |
| | | 这一批探针是为 **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`,时区解释不同会整体偏一天,通道只会回「交易不存在」——那会看起来像「钱丢了」。 |
| | | - 探针对通道只做**单次**查询,没有分页遍历:结算记录要翻页请缩小日期窗口;支付与商户信息本来就是单条查询。 |
| | | - 本目录的探针**没有配套的自动化测试**(它们是排查工具,不是生产代码路径)。 |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| New file |
| | |
| | | # 汇付排查探针的配置文件模板。 |
| | | # |
| | | # 用法:复制成 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 |
| New file |
| | |
| | | # 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 |
| | | } |
| New file |
| | |
| | | # 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 |
| | | } |
| New file |
| | |
| | | # 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 |
| | | } |
| New file |
| | |
| | | # 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 |
| | | } |
| New file |
| | |
| | | # 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 |
| | | } |