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
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
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://&lt;host&gt;:&lt;port&gt;/&lt;db&gt;?useUnicode=true&amp;characterEncoding=utf8&amp;serverTimezone=Asia/Shanghai&amp;useSSL=false
     * --db-user=&lt;用户&gt; --db-password=&lt;口令&gt;
     * --order-no=&lt;订单号&gt;          与 --payment-no 二选一
     * --payment-no=&lt;支付流水号&gt;
     * [--config=&lt;汇付配置文件路径&gt;]  不给就只查本地、不问通道
     * [--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();
    }
}