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;
|
|
/**
|
* 只读排查探针:一笔订单的「资金边界」——钱收没收到、核销没核销、退没退款——以及挂在它上面的通道记录。
|
*
|
* <p>这三列是同一行上的三个判断({@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} 里相关的未结问题。
|
*
|
* <p>什么时候用它:用户说「付了钱订单没变」「核销了商户没拿到钱」「退款了钱没退回来」。它把一条订单
|
* 上所有与钱有关的事实一次摆齐,再决定去哪条链路(对账、资金确认、悬账退款)继续查。
|
*
|
* <p>它不改任何状态:整个探针只有 SELECT,连通道都不问(因此不需要汇付配置)。
|
*
|
* <p>参数格式(全部从命令行传,缺参数直接打用法退出,不用任何默认值瞎猜):
|
* <pre>
|
* --db-url=<JDBC URL> --db-user=<用户> --db-password=<口令>
|
* --order-no=<订单号>
|
* [--config=<汇付配置文件路径>] 只为读 environment 打生产警告,本探针不做通道查询
|
* [--huifu-environment=test|prod] 同上,效果等价且在只给这一项时更方便
|
* </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 = 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<Map<String, Object>> 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<String, Object> order : orders) {
|
boundary(order);
|
payments(jdbc, order);
|
refunds(jdbc, order);
|
verifications(jdbc, order);
|
issues(jdbc, order);
|
System.out.println();
|
}
|
}
|
|
private static void boundary(Map<String, Object> 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<String, Object> order) {
|
List<Map<String, Object>> 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<String, Object> 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<String, Object> order) {
|
List<Map<String, Object>> 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<String, Object> 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<String, Object> order) {
|
List<Map<String, Object>> 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<String, Object> 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<String, Object> 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<Object> arguments = new ArrayList<>();
|
arguments.add(order.get("order_no"));
|
List<String> businessNos = new ArrayList<>();
|
for (Map<String, Object> 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<String, Object> 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<Map<String, Object>> rows = jdbc.queryForList(sql.toString(), arguments.toArray());
|
section("对账问题记录(payment_reconciliation_issue,共 " + rows.size() + " 条)");
|
if (rows.isEmpty()) {
|
System.out.println(" 没有:这个订单没有留下「钱与本地状态不符」的问题记录。");
|
return;
|
}
|
for (Map<String, Object> 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=<JDBC 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;
|
}
|
}
|