yeg
2026-09-18 db704fbed062d5700a37b7858231bf7d0958e38d
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
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=&lt;JDBC URL&gt; --db-user=&lt;用户&gt; --db-password=&lt;口令&gt;
     * --order-no=&lt;订单号&gt;
     * [--config=&lt;汇付配置文件路径&gt;]     只为读 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;
    }
}