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);
}
}