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