import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.DriverManagerDataSource; /** * 只读排查探针:一笔订单的「资金边界」——钱收没收到、核销没核销、退没退款——以及挂在它上面的通道记录。 * *

这三列是同一行上的三个判断({@code benefit_order}),核销、退款、资金确认都靠锁定这一行来串行化, * 所以任何一笔钱的问题都要先把这三列读出来。 */ public class OrderFundBoundaryProbe { /** 公网 IP 只留主机段前两段:地址本身(端口+路径)就是攻击面。 */ private static final Pattern AUTHORITY_IPV4 = Pattern.compile("^(\\d{1,3})\\.(\\d{1,3})\\.\\d{1,3}\\.\\d{1,3}(:\\d+)?$"); /** * 回答什么问题:给定订单号,本地 {@code benefit_order} 的资金边界三列 * ({@code order_status} / {@code verify_status} / {@code refund_status})、创建时固定的收款方 * ({@code collect_onboarding_id} / {@code collect_store_id} / {@code collect_huifu_id})、 * 关联的支付流水与退款记录、核销记录,以及 {@code payment_reconciliation_issue} 里相关的未结问题。 * *

什么时候用它:用户说「付了钱订单没变」「核销了商户没拿到钱」「退款了钱没退回来」。它把一条订单 * 上所有与钱有关的事实一次摆齐,再决定去哪条链路(对账、资金确认、悬账退款)继续查。 * *

它不改任何状态:整个探针只有 SELECT,连通道都不问(因此不需要汇付配置)。 * *

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

     * --db-url=<JDBC URL> --db-user=<用户> --db-password=<口令>
     * --order-no=<订单号>
     * [--config=<汇付配置文件路径>]     只为读 environment 打生产警告,本探针不做通道查询
     * [--huifu-environment=test|prod]    同上,效果等价且在只给这一项时更方便
     * 
*/ 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 = required(args, "order-no"); environmentBanner(arg(args, "config"), arg(args, "huifu-environment")); System.out.println("只读探针:整个探针只有 SELECT,连通道都不问。不发起支付、不关单、不退款、不交易确认、" + "不下发业务开通、不改任何数据库行。"); System.out.println("数据库目标:" + maskHost(dbUrl)); JdbcTemplate jdbc = new JdbcTemplate(new DriverManagerDataSource(dbUrl, dbUser, dbPassword)); List> orders = jdbc.queryForList(""" select id, order_no, order_type, talent_id, card_id, merchant_id, store_id, order_amount, subsidy_amount, pay_amount, order_status, verify_status, refund_status, collect_onboarding_id, collect_store_id, collect_huifu_id, expires_at, cancel_requested_at, deleted, created_at, updated_at from benefit_order where order_no = ? """, orderNo); if (orders.isEmpty()) { System.out.println(); System.out.println("本地没有这个订单号:" + orderNo); return; } for (Map order : orders) { boundary(order); payments(jdbc, order); refunds(jdbc, order); verifications(jdbc, order); issues(jdbc, order); System.out.println(); } } private static void boundary(Map order) { // 状态列是 tinyint,取成 int 才能用 switch(Java 的 switch 不支持 long 选择子)。 int orderStatus = (int) longValue(order.get("order_status"), -1L); int verifyStatus = (int) longValue(order.get("verify_status"), -1L); int refundStatus = (int) longValue(order.get("refund_status"), -1L); String orderStatusText = switch (orderStatus) { case 0 -> "待支付"; case 1 -> "已支付"; case 2 -> "已取消"; default -> ""; }; String refundStatusText = switch (refundStatus) { // 注意两套取值不一样:benefit_order.refund_status 是 0未退款 1退款中 2已退款, // 而 order_refund.refund_status 是 1退款中 2退款成功 3退款失败(见下面的退款记录)。 case 0 -> "未退款"; case 1 -> "退款中"; case 2 -> "已退款"; default -> ""; }; section("资金边界 / benefit_order#" + order.get("id")); line("order_no ", text(order.get("order_no"))); line("order_type ", text(order.get("order_type"))); line("merchant_id / store_id", order.get("merchant_id") + " / " + order.get("store_id")); line("order_status ", orderStatus + " " + orderStatusText); line("verify_status ", verifyStatus + " " + (verifyStatus == 1 ? "已核销" : verifyStatus == 0 ? "未核销" : "")); line("refund_status ", refundStatus + " " + refundStatusText); line("pay / order / subsidy", text(order.get("pay_amount")) + " / " + text(order.get("order_amount")) + " / " + text(order.get("subsidy_amount")) + " 分"); line("collect_onboarding_id", nullable(order.get("collect_onboarding_id"))); line("collect_store_id ", nullable(order.get("collect_store_id"))); line("collect_huifu_id ", maskId(order.get("collect_huifu_id"))); line("expires_at ", nullable(order.get("expires_at"))); line("cancel_requested_at", nullable(order.get("cancel_requested_at"))); line("created / updated", nullable(order.get("created_at")) + " / " + nullable(order.get("updated_at"))); if (longValue(order.get("deleted"), 0L) == 1L) { System.out.println(" ! deleted=1:这行被软删了。软删的订单也可能带着真实的支付流水," + "排查时不要跳过它。"); } System.out.println(" 读法:钱进商户基本户要满足 order_status=1 且 verify_status=1 且 refund_status=0" + "(资金确认重试就是这么筛的)。退款在途(refund_status=1)会让整单退出可核销与可确认范围。"); if (longValue(order.get("collect_onboarding_id"), 0L) == 0L) { System.out.println(" ! collect_onboarding_id 为空:这笔订单创建时没有固定收款子商户。" + "V131 之前的订单由平台商户收款,退款时按 HuifuRefundService 的那条回退走平台商户号;" + "现在下的单不该出现这种值。"); } } private static void payments(JdbcTemplate jdbc, Map order) { List> rows = jdbc.queryForList(""" select id, payment_no, provider, provider_huifu_id, amount, status, funds_confirmation_status, idempotency_key, attempt_no, provider_transaction_no, close_attempts, created_at, paid_at, closed_at from payment_transaction where order_id = ? or order_no = ? order by id asc """, order.get("id"), order.get("order_no")); section("支付流水(payment_transaction,共 " + rows.size() + " 条)"); if (rows.isEmpty()) { System.out.println(" 一条都没有:这笔订单从来没有发起过支付(0 元单或纯补贴单也可能是这样)。"); return; } for (Map row : rows) { line(" #" + row.get("id"), text(row.get("payment_no")) + " | " + text(row.get("provider")) + " | status=" + text(row.get("status")) + " | funds=" + nullable(row.get("funds_confirmation_status")) + " | " + text(row.get("amount")) + " 分" + " | provider_huifu_id=" + maskId(row.get("provider_huifu_id"))); line(" 幂等键/尝试", text(row.get("idempotency_key")) + " / #" + text(row.get("attempt_no")) + " | close_attempts=" + text(row.get("close_attempts")) + " | hf_seq=" + nullable(row.get("provider_transaction_no"))); line(" 时间 ", nullable(row.get("created_at")) + " -> paid " + nullable(row.get("paid_at")) + " / closed " + nullable(row.get("closed_at"))); } System.out.println(" 读法:funds_confirmation_status NULL=这笔没走延迟入账;pending=钱还在通道延迟户" + "(不可结算、不可取现);confirmed=已进基本户;reverting=回退在途(撤销核销要把钱要回来)。" + "状态是 CLOSING / CANCEL_UNCONFIRMED 表示这次尝试的关单还没结论。"); } private static void refunds(JdbcTemplate jdbc, Map order) { List> rows = jdbc.queryForList(""" select id, refund_no, refund_status, paid_amount, refund_amount, refundable_amount, provider, provider_refund_no, provider_original_no, provider_huifu_id, provider_response_code, provider_response_message, reason, refund_time, created_at, deleted from order_refund where order_id = ? or order_no = ? order by id asc """, order.get("id"), order.get("order_no")); section("退款记录(order_refund,共 " + rows.size() + " 条)"); if (rows.isEmpty()) { System.out.println(" 没有退款记录。"); return; } for (Map row : rows) { int status = (int) longValue(row.get("refund_status"), -1L); String statusText = switch (status) { // order_refund 的取值与 benefit_order.refund_status 不同名也不同义:1中 2成功 3失败。 case 1 -> "退款中"; case 2 -> "退款成功"; case 3 -> "退款失败"; default -> ""; }; line(" #" + row.get("id"), text(row.get("refund_no")) + " | refund_status=" + status + " " + statusText + " | 退 " + text(row.get("refund_amount")) + " 分" + " | 已付 " + text(row.get("paid_amount")) + " 分" + " | 可退 " + text(row.get("refundable_amount")) + " 分"); line(" 通道单号 ", text(row.get("provider")) + " | provider_refund_no=" + nullable(row.get("provider_refund_no")) + " | provider_original_no=" + nullable(row.get("provider_original_no")) + " | provider_huifu_id=" + maskId(row.get("provider_huifu_id"))); line(" 通道回执 ", nullable(row.get("provider_response_code")) + " " + nullable(row.get("provider_response_message"))); line(" 时间/原因 ", nullable(row.get("refund_time")) + " | " + nullable(row.get("reason"))); if (longValue(row.get("deleted"), 0L) == 1L) { System.out.println(" ! deleted=1:软删的退款记录。「可退金额」是按未删的行算的," + "而软删的行仍然占着通道的退款流水号。"); } } } private static void verifications(JdbcTemplate jdbc, Map order) { List> rows = jdbc.queryForList(""" select id, verify_no, verify_type, verify_amount, subsidy_amount, status, verify_time, revoked_at, revoke_reason from verify_record where order_id = ? order by id asc """, order.get("id")); section("核销记录(verify_record,共 " + rows.size() + " 条;同一订单同时只能有一条 status=1)"); if (rows.isEmpty()) { System.out.println(" 没有核销记录:钱还在通道延迟户,商户拿不到。"); return; } for (Map row : rows) { line(" #" + row.get("id"), text(row.get("verify_no")) + " | " + text(row.get("verify_type")) + " | status=" + text(row.get("status")) + (longValue(row.get("status"), -1L) == 1L ? "(有效)" : "(已撤销)") + " | 核销 " + text(row.get("verify_amount")) + " 分 / 补贴 " + text(row.get("subsidy_amount")) + " 分"); line(" 时间/撤销 ", nullable(row.get("verify_time")) + " | revoked_at " + nullable(row.get("revoked_at")) + " | " + nullable(row.get("revoke_reason"))); } } private static void issues(JdbcTemplate jdbc, Map order) { StringBuilder sql = new StringBuilder(""" select id, issue_type, business_no, order_no, expected_status, actual_status, amount, detail_message, status, detected_at, resolved_at, resolved_by from payment_reconciliation_issue where order_no = ? """); List arguments = new ArrayList<>(); arguments.add(order.get("order_no")); List businessNos = new ArrayList<>(); for (Map payment : jdbc.queryForList( "select payment_no from payment_transaction where order_id = ? or order_no = ?", order.get("id"), order.get("order_no"))) { String value = text(payment.get("payment_no")); if (!value.isBlank()) businessNos.add(value); } for (Map refund : jdbc.queryForList( "select refund_no from order_refund where order_id = ? or order_no = ?", order.get("id"), order.get("order_no"))) { String value = text(refund.get("refund_no")); if (!value.isBlank()) businessNos.add(value); } if (!businessNos.isEmpty()) { sql.append(" or business_no in ("); sql.append("?,".repeat(businessNos.size())); sql.setLength(sql.length() - 1); sql.append(")"); arguments.addAll(businessNos); } sql.append(" order by id desc"); List> rows = jdbc.queryForList(sql.toString(), arguments.toArray()); section("对账问题记录(payment_reconciliation_issue,共 " + rows.size() + " 条)"); if (rows.isEmpty()) { System.out.println(" 没有:这个订单没有留下「钱与本地状态不符」的问题记录。"); return; } for (Map row : rows) { line(" #" + row.get("id"), text(row.get("issue_type")) + " | business_no=" + text(row.get("business_no")) + " | status=" + text(row.get("status"))); line(" 期望/实际 ", nullable(row.get("expected_status")) + " -> " + nullable(row.get("actual_status")) + " | " + text(row.get("amount")) + " 分"); line(" 说明 ", nullable(row.get("detail_message"))); line(" 发现/解决 ", nullable(row.get("detected_at")) + " | " + nullable(row.get("resolved_at")) + " / " + nullable(row.get("resolved_by"))); } System.out.println(" status=open 的行是未结问题:它不会自己消失,会一直被对账重新检出,需要人工给结论。"); } // ---------- 参数与输出 ---------- /** 本探针不问通道,但环境仍然要打招呼:--config 或 --huifu-environment 给了就按它判断。 */ private static void environmentBanner(String configPath, String environmentOverride) { String environment = environmentOverride == null ? "" : environmentOverride.trim(); if (environment.isBlank() && configPath != null && !configPath.isBlank()) { environment = environmentFromConfig(configPath); } if (environment.isBlank()) { System.out.println("! 环境未声明(未给 --config 或 --huifu-environment):本探针只读,但请自行确认下面的" + "数据库目标就是你要排查的那个库,不要在生产库上随手跑。"); return; } if (!"prod".equalsIgnoreCase(environment)) { System.out.println("环境:" + environment + "(app.payment.huifu.environment)"); return; } System.out.println("!! ================================================================================= !!"); System.out.println("!! app.payment.huifu.environment = prod:目标环境是生产。 !!"); System.out.println("!! 本探针只读(整个探针只有 SELECT,连通道都不问),不写任何一行。 !!"); System.out.println("!! ================================================================================= !!"); } /** 只从配置文件里取 environment 一个键;私钥、密钥等一律不读进内存。 */ private static String environmentFromConfig(String path) { java.util.Properties source = new java.util.Properties(); try (java.io.Reader reader = java.nio.file.Files.newBufferedReader( java.nio.file.Path.of(path), StandardCharsets.UTF_8)) { source.load(reader); String value = source.getProperty("app.payment.huifu.environment"); if (value == null || value.isBlank()) value = source.getProperty("environment"); return value == null ? "" : value.trim(); } catch (java.io.IOException exception) { System.out.println("! 读不了配置文件 " + path + ":" + exception.getMessage() + "(继续按未声明环境处理)"); return ""; } } /** * 汇付号只留前 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; } 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("用法:OrderFundBoundaryProbe --db-url= --db-user=<用户> --db-password=<口令>" + " --order-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 long longValue(Object value, long fallback) { return value instanceof Number number ? number.longValue() : fallback; } }