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; /** * 只读排查探针:问通道「钱到底结了多少到这家商户的银行卡」,按日期区间直查结算记录。 * *

出款改由斗拱按结算周期自动完成之后,平台侧不再有提现记录,「每天结了多少」只有通道知道。 */ public class SettlementRecordProbe { /** * 通道对 {@code pageSize} 的硬上限,与 {@code HuifuSettlementRecordGateway.PROVIDER_MAX_PAGE_SIZE} 同一个值。 * *

实测超过 50 时接口直接返回 {@code 00000003} * 「基础参数校验失败:【pageSize-分页条数】不能为空,且最小值为0,最大值为50」,一条记录都拿不到。 * 后台此前默认传 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} 里这家商户 * 结了多少笔、多少钱、结算到哪张卡(卡号一律不打印)。 * *

什么时候用它:商户说「钱没到账」、或者后台「结算记录」页空白时——先确认通道侧到底有没有结算流水, * 再区分是「还没到结算周期」还是「通道里根本没有这笔钱的流水」(后者要去查支付与资金确认)。 * *

它不改任何状态:只调用通道的结算记录查询接口,不发起支付、不关单、不退款、不交易确认、 * 不下发业务开通、连本地数据库都不连。 * *

参数格式(全部从命令行传,缺参数直接打用法退出,不用任何默认值瞎猜): *

     * --config=<汇付配置文件路径>        必填:通道查询要先有 system-id / product-id / 私钥 / 平台公钥
     * --huifu-id=<斗拱商户号>           必填
     * --begin-date=yyyyMMdd --end-date=yyyyMMdd   必填
     * [--page-size=<1..50>]            默认 50(通道上限);越界值会被收敛,不会被发到通道
     * [--huifu-environment=test|prod]
     * 
*/ 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)。"); } // ---------- 参数、配置与输出 ---------- /** 读汇付配置。不读 application.yml:私钥与密钥只在本探针内部使用,从不打印。 */ 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 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(); } }