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} 的单跑版)。 * *

应用里那份 {@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} * 推导出来的、有没有哪个配了但服务端根本没有对应入口。 * *

什么时候用它:回调没到、商户开通卡在「等待通道通知」、或者换了域名/IP 之后要确认通道侧到底会 * 往哪个地址投递。它也回答一句常被忽略的话:某个回调地址留空不等于「不回调」——四个非支付回调 * 在配置为空时会由 {@code notify-url} 推导出同主机同前缀的地址(见 * {@code HuifuPaymentProperties#callbackUrl})。真正的「回调未启用」只有一种:那个地址为空串。 * *

它不改任何状态:不启动 Spring、不连数据库、不连通道,只读一个配置文件。 * *

参数格式(全部从命令行传,缺参数直接打用法退出;不给默认地址): *

     * --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=... 等参数逐个覆盖配置文件里的同名项。
     * 
*/ 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 report = audit.report(); System.out.println(); System.out.println("== 五个回调地址(取值规则与启动时的 HuifuCallbackUrlAudit 完全同一套)=="); List empty = new ArrayList<>(); List derived = new ArrayList<>(); List badPath = new ArrayList<>(); List 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); } }