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