yeg
2026-09-14 5364ba7487eb7abe0d4adc67f3393f511ed123a7
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
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
package com.talentcard.server.business.merchant;
 
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
 
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
 
import com.alibaba.fastjson.JSON;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.business.merchant.AutoWithdrawalResultService.Application;
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
import com.talentcard.server.business.order.OrderRefundRequest;
import com.talentcard.server.business.payment.HuifuPaymentCallbackService;
import com.talentcard.server.business.payment.HuifuPaymentService;
import com.talentcard.server.business.payment.HuifuRefundService;
import com.talentcard.server.business.payment.huifu.HuifuIndividualOnboardingGateway;
import com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway;
import com.talentcard.server.business.payment.huifu.HuifuMerchantCapabilityGateway;
import com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway;
import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentCommand;
import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway;
import com.talentcard.server.business.payment.huifu.HuifuPictureUploadGateway;
import com.talentcard.server.business.payment.huifu.HuifuRefundGateway;
import com.talentcard.server.business.payment.huifu.HuifuSignatureService;
import com.talentcard.server.business.payment.huifu.HuifuWithdrawalCommand;
import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway;
import com.talentcard.server.business.payment.huifu.HuifuWithdrawalGateway;
import com.talentcard.server.business.payment.huifu.HuifuWithdrawalQueryCommand;
import com.talentcard.server.business.portal.AppOrderCreateRequest;
import com.talentcard.server.business.portal.AppOrderPortalService;
import com.talentcard.server.common.BusinessException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
 
/**
 * 完整链路验收 seam(工单 06):从真实进件入口一路走到提现到账,全部用生产服务而不是直接写库造数。
 *
 * <p>链路:{@code submitOnboarding} → 斗拱进件与绑卡结果 → 微信商户配置 → 商户业务开通回调 →
 * 收款能力快照 → 下单固定收款子商户 → 支付预下单 → 验签回调落账 → 商户核销 → 次日凌晨日汇总 →
 * 提交主动取现 → 异步通知/主动查询收敛 → 商户端只看到三种状态。
 *
 * <p>共享测试库不清空,也不回滚,因此:断言只看本用例自己创建的商户/订单/提现记录;业务日取一个
 * 足够远的未来日期,避免别的用例留下的记录落进同一个汇总窗口;后台任务在测试 profile 里是关闭的,
 * 用例自己直接调用汇总和结果处理服务。
 *
 * <p>这里用 {@code @MockitoBean} 替换所有斗拱网关,所以整条链路零出网调用。真实沙盒和受控真实小额
 * 到账验证的步骤、白名单与金额上限约束见 {@code docs/automatic-withdrawal-acceptance.md}。
 */
@SpringBootTest
@ActiveProfiles("test")
@TestPropertySource(properties = {
        // 打开通道与能力校验,才能走到「能力就绪才允许支付」的真实闸口。
        "app.payment.huifu.enabled=true",
        "app.payment.huifu.merchant-capability-required=true",
        // 关掉两个定时任务:它们会扫描全表并改动别的用例正在断言的快照。
        "app.payment.huifu.capability-refresh.enabled=false",
        "app.merchant.auto-withdrawal.result-check-enabled=false"
})
class AutoWithdrawalEndToEndAcceptanceTests {
 
    /** 未来业务日的随机起点 + 每次调用递增,保证同一次运行内每个用例的业务日互不相同。 */
    private static final AtomicLong BUSINESS_DATE_OFFSET =
            new AtomicLong(30_000 + ThreadLocalRandom.current().nextLong(1_000_000));
 
    /** 单人单笔的极小金额:既高于默认最低提现金额 1000 分,又便于人工核对到账金额。 */
    private static final long PAY_AMOUNT_CENTS = 1200;
 
    private static final String BRANCH_CODE = "102100099996";
 
    @Autowired
    private JdbcTemplate jdbc;
 
    @Autowired
    private MerchantSettlementService settlementService;
 
    @Autowired
    private MerchantPaymentReadinessService readinessService;
 
    @Autowired
    private AppOrderPortalService appOrderPortalService;
 
    @Autowired
    private HuifuPaymentService paymentService;
 
    @Autowired
    private HuifuPaymentCallbackService callbackService;
 
    @Autowired
    private HuifuRefundService refundService;
 
    @Autowired
    private MerchantWorkService merchantWorkService;
 
    @Autowired
    private AutoWithdrawalAggregationService aggregation;
 
    @Autowired
    private AutoWithdrawalResultService withdrawalResultService;
 
    /** 路由表真源:用来证明商户端/后台都没有提现写入口(提现状态只能由通道收敛)。 */
    @Autowired
    @Qualifier("requestMappingHandlerMapping")
    private RequestMappingHandlerMapping handlerMapping;
 
    @MockitoBean
    private HuifuSignatureService signatureService;
 
    @MockitoBean
    private HuifuPictureUploadGateway pictureUploadGateway;
 
    @MockitoBean
    private HuifuIndividualOnboardingGateway individualOnboardingGateway;
 
    @MockitoBean
    private HuifuMerchantWechatConfigGateway merchantWechatConfigGateway;
 
    @MockitoBean
    private HuifuMerchantBusinessOpenGateway merchantBusinessOpenGateway;
 
    @MockitoBean
    private HuifuMerchantCapabilityGateway capabilityGateway;
 
    @MockitoBean
    private HuifuMiniProgramPaymentGateway paymentGateway;
 
    @MockitoBean
    private HuifuRefundGateway refundGateway;
 
    @MockitoBean
    private HuifuWithdrawalGateway withdrawalGateway;
 
    @MockitoBean
    private HuifuDelayAccountGateway delayAccountGateway;
 
    private Long merchantId;
    private Long storeId;
    private Long talentId;
    private Long productId;
    private Long onboardingId;
    private String huifuId;
    private String suffix;
    private MerchantPrincipal principal;
 
    @BeforeEach
    void prepareMerchantAndProviderAnswers() {
        suffix = String.valueOf(System.nanoTime());
        // 测试库的网点目录是空的(测试 profile 关掉了 10 万行导入),进件要求开户行真实存在。
        jdbc.update("insert ignore into bank_branch_directory(clearing_code, branch_name, bank_name, head_bank_code) "
                + "values (?, '中国建设银行攀枝花东区支行营业部', '中国建设银行', '105100000017')", BRANCH_CODE);
        jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)",
                "acceptance-openid-" + suffix, "验收用例用户", "13900000006");
        talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class);
        jdbc.update("insert into talent_card(talent_id, card_no, card_type, card_level, status, deleted) "
                + "values (?, ?, 'PERSONAL', ?, 1, 0)", talentId, "TC-ACC-" + suffix, "卓越人才卡");
        jdbc.update("insert into merchant(merchant_name, contact_mobile, address, status, deleted) "
                + "values (?, ?, ?, 1, 0)", "Acceptance merchant " + suffix, "08120000006", "攀枝花市东区验收路 1 号");
        merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class);
        jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)",
                merchantId, "Acceptance store " + suffix);
        storeId = jdbc.queryForObject("select max(id) from merchant_store", Long.class);
        jdbc.update("""
                insert into merchant_product(product_no, merchant_id, product_type, product_name, original_price,
                                             sale_price, subsidy_amount, stock, audit_status, status, deleted)
                values (?, ?, 'food', '验收权益商品', 20.00, 12.00, 0.00, 10, 2, 1, 0)
                """, "ACCEPTANCE-PRODUCT-" + suffix, merchantId);
        productId = jdbc.queryForObject("select max(id) from merchant_product", Long.class);
        principal = new MerchantPrincipal(9901L, merchantId, storeId, "operator");
        // 核销会触发延迟交易的资金确认。这条链路的重点是提现,所以让确认直接成功;
        // 「未确认不能提现」由 AutoWithdrawalAggregationTests 与 PaymentFundConfirmationTests 覆盖。
        when(delayAccountGateway.confirm(any())).thenReturn(delayConfirmed());
 
        when(signatureService.verifyCallbackPayload(any(), any())).thenReturn(true);
        when(pictureUploadGateway.uploadUrl(any(), any())).thenReturn("HP-" + suffix);
        // 通道答复:能力就绪(自动结算已关闭 + 取现可用 + 子商户正常)。
        when(capabilityGateway.query(any())).thenReturn(new HuifuMerchantCapabilityGateway.CapabilityResult(
                true, "00000000", "成功", "NORMAL", "0", "D1", "1", "DM"));
        when(individualOnboardingGateway.create(any())).thenReturn(
                new HuifuIndividualOnboardingGateway.Result(true, shortReference("H", suffix), "MO-APPLY-" + suffix,
                        shortReference("T", suffix), "00000000", "成功"));
        when(merchantWechatConfigGateway.configure(any(), any())).thenReturn(
                new HuifuMerchantWechatConfigGateway.Result(true, "00000000", "成功"));
        when(merchantBusinessOpenGateway.open(any(), any(), any(), any())).thenReturn(
                new HuifuMerchantBusinessOpenGateway.Result(true, "BO-APPLY-" + suffix, "00000000", "成功"));
 
        // 两个任务都会扫描全表,所以两个窗口都要收拾干净:把别的用例留下的记录移出任务窗口
        // (而不是删掉它们),这样断言和 verify 只针对本用例自己创建的记录。
        jdbc.update("""
                update merchant_withdrawal set withdrawal_date = null
                where status = 'pending' and withdrawal_date is not null and merchant_id <> ?
                """, merchantId);
        jdbc.update("""
                update merchant_withdrawal set provider_request_date = null
                where status = 'processing' and provider_request_date is not null and merchant_id <> ?
                """, merchantId);
    }
 
    @Test
    void theWholeChainTakesAMerchantFromOnboardingToAPaidWithdrawal() {
        onboardIndividualMerchant();
 
        assertThat(applicationRow())
                .containsEntry("status", "approved")
                .containsEntry("wechat_config_status", "approved")
                .containsEntry("business_open_status", "approved")
                .containsEntry("provider_huifu_id", huifuId);
        assertThat(cardRow())
                .containsEntry("status", "approved")
                .containsEntry("provider_token_no", shortReference("T", suffix));
        // 进件通过 → 能力快照刷新 → 商户能收款,这是工单 01 的完整判定链。
        assertThat(onboardingStatus()).isEqualTo("approved");
        assertThat(capabilityRow())
                .containsEntry("merchant_stat", "NORMAL")
                .containsEntry("auto_settle_status", "0")
                .containsEntry("cash_switch_status", "1");
        assertThat(readinessService.readiness(merchantId, storeId).reason()).isEmpty();
        assertThat(readinessService.readiness(merchantId, storeId).ready()).isTrue();
        assertThat(readinessService.requireCollectingMerchant(merchantId, storeId).huifuId()).isEqualTo(huifuId);
 
        String orderNo = createPayableOrder();
        assertThat(orderRow(orderNo))
                .containsEntry("collect_onboarding_id", onboardingId)
                .containsEntry("collect_huifu_id", huifuId);
        assertThat(orderRow(orderNo).get("collect_store_id")).isEqualTo(storeId);
 
        // 支付预下单只使用订单固定的收款子商户。
        Map<String, Object> payment = paymentService.beginMiniProgramPayment(
                orderNo, talentId, 1, "acceptance-payment-" + suffix);
        String paymentNo = String.valueOf(payment.get("paymentNo"));
        ArgumentCaptor<HuifuMiniProgramPaymentCommand> paymentCommand =
                ArgumentCaptor.forClass(HuifuMiniProgramPaymentCommand.class);
        verify(paymentGateway).createMiniProgramPayment(paymentCommand.capture());
        assertThat(paymentCommand.getValue().huifuId()).isEqualTo(huifuId);
        assertThat(payment.get("paymentStatus")).isEqualTo("PAYMENT_PENDING");
 
        // 验签回调落账:支付流水成功、订单已支付(未核销,核销是商户的动作)。
        assertThat(callbackService.handle(paymentCallback(paymentNo, amountYuan(PAY_AMOUNT_CENTS)), "signature"))
                .isEqualTo("RECV_ORD_ID_" + paymentNo);
        assertThat(paymentRow(paymentNo))
                .containsEntry("status", "SUCCEEDED")
                .containsEntry("provider_huifu_id", huifuId);
        assertThat(orderRow(orderNo))
                .containsEntry("order_status", 1)
                .containsEntry("verify_status", 0)
                .containsEntry("withdrawal_status", 0);
 
        // 商户核销,并把这笔核销挪到业务日窗口内——验收要的是「次日汇总」,不是等一天。
        LocalDate businessDate = someBusinessDate();
        merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "acceptance"), verifyKey(orderNo));
        Long orderId = orderId(orderNo);
        verifiedOn(orderId, businessDate);
 
        assertThat(aggregation.run(businessDate).merchantsAggregated()).isPositive();
        Withdrawal withdrawal = withdrawalOf(businessDate, orderId);
        assertThat(withdrawal.amountCents()).isEqualTo(PAY_AMOUNT_CENTS);
        assertThat(sourceOrderIds(withdrawal.id())).containsExactly(orderId);
        assertThat(orderRow(orderNo).get("withdrawal_status")).isEqualTo(1);
        // 还没提交给斗拱的记录,商户端也是「处理中」,不是「待审核」。
        assertThat(merchantWithdrawals()).singleElement().satisfies(row -> assertThat(row)
                .containsEntry("status", "processing")
                .containsEntry("amountCents", PAY_AMOUNT_CENTS));
 
        when(withdrawalGateway.create(any())).thenReturn(sync(Map.of(
                "resp_code", "00000000", "resp_desc", "成功", "trans_stat", "P", "hf_seq_id", "HF-W-P-" + suffix)));
        assertThat(withdrawalResultService.submitPending(200)).isPositive();
        ArgumentCaptor<HuifuWithdrawalCommand> withdrawalCommand =
                ArgumentCaptor.forClass(HuifuWithdrawalCommand.class);
        verify(withdrawalGateway).create(withdrawalCommand.capture());
        assertThat(withdrawalCommand.getValue().withdrawalNo()).isEqualTo(withdrawal.withdrawalNo());
        assertThat(withdrawalCommand.getValue().amountCents()).isEqualTo(PAY_AMOUNT_CENTS);
        assertThat(withdrawalCommand.getValue().huifuId()).isEqualTo(huifuId);
        assertThat(withdrawalCommand.getValue().tokenNo()).isEqualTo(shortReference("T", suffix));
        // 通道受理但结果未定:查询要用提交时记下的营业日和子商户号。
        assertThat(withdrawalRow(withdrawal.id()))
                .containsEntry("status", "processing")
                .containsEntry("provider_huifu_id", huifuId);
 
        assertThat(withdrawalResultService.handleNotification(withdrawal.withdrawalNo(),
                withdrawalNotification("S", "00000000", "到账成功"))).isEqualTo(Application.PAID);
        assertThat(withdrawalRow(withdrawal.id()))
                .containsEntry("status", "paid")
                .containsEntry("amount_cents", PAY_AMOUNT_CENTS);
        assertThat(withdrawalRow(withdrawal.id()).get("paid_at")).isNotNull();
        assertOnlyWithdrawalStatus("paid");
        // 到账之后金额冻结在提现记录里,可提现金额归零。
        assertThat(settlementService.withdrawalSummary(principal))
                .containsEntry("availableAmountCents", 0L)
                .containsEntry("frozenAmountCents", PAY_AMOUNT_CENTS);
    }
 
    @Test
    void aProcessingWithdrawalIsConvergedByTheScheduledQueryInsteadOfByHand() {
        readyMerchant();
        LocalDate businessDate = someBusinessDate();
        String orderNo = paidVerifiedOrder(businessDate);
        Long orderId = orderId(orderNo);
        aggregation.run(businessDate);
        Withdrawal withdrawal = withdrawalOf(businessDate, orderId);
 
        when(withdrawalGateway.create(any())).thenReturn(sync(Map.of(
                "resp_code", "00000000", "trans_stat", "P", "hf_seq_id", "HF-W-Q-" + suffix)));
        withdrawalResultService.submitPending(200);
        assertThat(withdrawalRow(withdrawal.id())).containsEntry("status", "processing");
        assertOnlyWithdrawalStatus("processing");
 
        // 等待期内的记录不查询:刚提交时通道可能还没落库,过早查询会把「查不到」当成失败。
        assertThat(withdrawalResultService.reconcileProcessing(200)).isZero();
        verify(withdrawalGateway, never()).query(any());
 
        ageProcessing(withdrawal.id());
        when(withdrawalGateway.query(any())).thenReturn(sync(Map.of(
                "resp_code", "00000000", "trans_status", "S", "trans_desc", "", "org_hf_seq_id", "HF-W-QS-" + suffix)));
        assertThat(withdrawalResultService.reconcileProcessing(200)).isPositive();
 
        ArgumentCaptor<HuifuWithdrawalQueryCommand> query =
                ArgumentCaptor.forClass(HuifuWithdrawalQueryCommand.class);
        verify(withdrawalGateway).query(query.capture());
        assertThat(query.getValue().withdrawalNo()).isEqualTo(withdrawal.withdrawalNo());
        assertThat(query.getValue().huifuId()).isEqualTo(huifuId);
        assertThat(withdrawalRow(withdrawal.id()))
                .containsEntry("status", "paid")
                .containsEntry("provider_trade_no", "HF-W-QS-" + suffix);
        assertOnlyWithdrawalStatus("paid");
    }
 
    @Test
    void aFailedWithdrawalDefersItsOrdersIntoTheNextBusinessDay() {
        readyMerchant();
        LocalDate businessDate = someBusinessDate();
        Long firstOrder = orderId(paidVerifiedOrder(businessDate));
        aggregation.run(businessDate);
        Withdrawal failed = withdrawalOf(businessDate, firstOrder);
 
        when(withdrawalGateway.create(any())).thenReturn(sync(Map.of(
                "resp_code", "00000000", "trans_stat", "P", "hf_seq_id", "HF-W-F-" + suffix)));
        withdrawalResultService.submitPending(200);
        assertThat(withdrawalResultService.handleNotification(failed.withdrawalNo(),
                withdrawalNotification("F", "00000000", "账务失败"))).isEqualTo(Application.FAILED);
 
        assertThat(withdrawalRow(failed.id()))
                .containsEntry("status", "failed")
                .containsEntry("failure_reason", "账务失败")
                .containsEntry("amount_cents", PAY_AMOUNT_CENTS);
        // 失败顺延:订单解锁回到可汇总范围,关联行留痕但标记为已释放。
        assertThat(orderRow(firstOrder)).containsEntry("withdrawal_status", 0);
        assertThat(releasedAt(failed.id(), firstOrder)).isNotNull();
        // 商户端把失败原因一并展示出来,状态仍然是三种之一。
        assertThat(merchantWithdrawals()).singleElement().satisfies(row -> assertThat(row)
                .containsEntry("status", "failed")
                .containsEntry("reason", "账务失败"));
 
        // 次日汇总必须把顺延的订单和新订单合并成一条新的提现记录。
        Long secondOrder = orderId(paidVerifiedOrder(businessDate.plusDays(1)));
        aggregation.run(businessDate.plusDays(1));
 
        Withdrawal retried = withdrawalOf(businessDate.plusDays(1), firstOrder);
        assertThat(retried.amountCents()).isEqualTo(2 * PAY_AMOUNT_CENTS);
        assertThat(sourceOrderIds(retried.id())).containsExactlyInAnyOrder(firstOrder, secondOrder);
        assertThat(orderRow(firstOrder).get("withdrawal_status")).isEqualTo(1);
        assertThat(orderRow(secondOrder).get("withdrawal_status")).isEqualTo(1);
    }
 
    @Test
    void repeatedProviderNotificationsAndRepeatedVerificationNeverChangeSettledState() {
        readyMerchant();
        LocalDate businessDate = someBusinessDate();
        String orderNo = paidVerifiedOrder(businessDate);
        Long orderId = orderId(orderNo);
 
        // 核销幂等:同一个幂等键重复提交只产生一条有效核销记录,不同幂等键被订单状态挡住。
        assertThat(activeVerifyRecords(orderId)).isEqualTo(1);
        merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "again"), verifyKey(orderNo));
        assertThat(activeVerifyRecords(orderId)).isEqualTo(1);
        assertThatThrownBy(() -> merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "again"),
                "acceptance-verify-second-" + suffix + "-" + orderNo))
                .isInstanceOf(BusinessException.class);
        assertThat(activeVerifyRecords(orderId)).isEqualTo(1);
 
        aggregation.run(businessDate);
        Withdrawal withdrawal = withdrawalOf(businessDate, orderId);
        when(withdrawalGateway.create(any())).thenReturn(sync(Map.of(
                "resp_code", "00000000", "trans_stat", "S", "hf_seq_id", "HF-W-DUP-" + suffix)));
        withdrawalResultService.submitPending(200);
        assertThat(withdrawalRow(withdrawal.id())).containsEntry("status", "paid");
 
        // 重复通知(含通道的第二次账务通知)不能改写终态,也不会释放来源订单。
        assertThat(withdrawalResultService.handleNotification(withdrawal.withdrawalNo(),
                withdrawalNotification("S", "00000000", "到账成功"))).isEqualTo(Application.ALREADY_SETTLED);
        assertThat(withdrawalResultService.handleNotification(withdrawal.withdrawalNo(),
                withdrawalNotification("F", "00000000", "迟到的失败"))).isEqualTo(Application.ALREADY_SETTLED);
        assertThat(withdrawalRow(withdrawal.id())).containsEntry("status", "paid");
        assertThat(orderRow(orderId)).containsEntry("withdrawal_status", 1);
 
        // 重复执行结果处理任务:终态记录不会被重新提交,也就不会用新的业务请求号重复出账。
        assertThat(withdrawalResultService.submitPending(200)).isZero();
        verify(withdrawalGateway).create(any());
    }
 
    @Test
    void aRefundUnderWayExcludesTheOrderFromTheDailyWithdrawal() {
        readyMerchant();
        LocalDate businessDate = someBusinessDate();
        // 退款的订单必须是「已支付未核销」:核销过的订单按既有策略不可退。
        String orderNo = paidOrder();
        Long orderId = orderId(orderNo);
        Refund refund = requestRefundUnderWay(orderNo);
 
        aggregation.run(businessDate);
 
        // 退款在途(整单排除)的订单不进提现记录,金额留作退款。
        assertThat(withdrawalCount(businessDate)).isZero();
        assertThat(orderRow(orderId)).containsEntry("withdrawal_status", 0);
 
        // 通道确认退款成功后,这笔订单永远退出提现范围。
        assertThat(callbackService.handle(refundCallback(refund.refundNo(), "S", "00000000"), "signature"))
                .isEqualTo("RECV_ORD_ID_" + refund.refundNo());
        assertThat(orderRow(orderId))
                .containsEntry("refund_status", 2)
                .containsEntry("withdrawal_status", 0);
        assertThat(withdrawalCount(businessDate)).isZero();
    }
 
    @Test
    void anOrderLockedIntoAWithdrawalRecordCanNeitherBeRevokedNorRefunded() {
        readyMerchant();
        LocalDate businessDate = someBusinessDate();
        String orderNo = paidVerifiedOrder(businessDate);
        Long orderId = orderId(orderNo);
 
        aggregation.run(businessDate);
        Withdrawal withdrawal = withdrawalOf(businessDate, orderId);
        assertThat(orderRow(orderId)).containsEntry("withdrawal_status", 1);
 
        // 已经进入提现记录的订单不能再撤销核销或退款:这是「退款与批次锁定按订单串行化」的终点。
        assertThatThrownBy(() -> merchantWorkService.revokeVerification(principal,
                new MerchantRevokeVerificationRequest(String.valueOf(orderId), "too late"), "acceptance-revoke-" + suffix))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("提现记录");
        assertThatThrownBy(() -> refundService.request(orderNo,
                new OrderRefundRequest(BigDecimal.valueOf(PAY_AMOUNT_CENTS, 2), "too late", "tester")))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("提现记录");
        assertThat(merchantWorkService.order(orderNo, merchantId, storeId))
                .containsEntry("canRevoke", false)
                .containsEntry("revokeReason", "订单已进入提现记录");
 
        // 撤销与退款都失败后订单状态不变,金额仍在这条提现记录里等待通道结果。
        assertThat(orderRow(orderId))
                .containsEntry("verify_status", 1)
                .containsEntry("refund_status", 0)
                .containsEntry("withdrawal_status", 1);
        assertThat(activeVerifyRecords(orderId)).isEqualTo(1);
        assertThat(withdrawalRow(withdrawal.id())).containsEntry("amount_cents", PAY_AMOUNT_CENTS);
    }
 
    @Test
    void anUnpaidOrderCannotBeVerifiedAndNeverReachesAWithdrawal() {
        readyMerchant();
        LocalDate businessDate = someBusinessDate();
        String orderNo = createPayableOrder();
        Long orderId = orderId(orderNo);
        assertThat(orderRow(orderNo)).containsEntry("order_status", 0);
 
        assertThatThrownBy(() -> merchantWorkService.verify(orderNo, principal,
                new VerifyRequest("merchant", "unpaid"), verifyKey(orderNo)))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("未支付");
        assertThat(activeVerifyRecords(orderId)).isZero();
 
        aggregation.run(businessDate);
        assertThat(withdrawalCount(businessDate)).isZero();
        assertThat(orderRow(orderId)).containsEntry("withdrawal_status", 0);
    }
 
    @Test
    void aForgedOrTamperedPaymentCallbackNeverMarksTheOrderPaid() {
        readyMerchant();
        String orderNo = createPayableOrder();
        String paymentNo = String.valueOf(paymentService.beginMiniProgramPayment(
                orderNo, talentId, 1, "acceptance-payment-forged-" + suffix).get("paymentNo"));
        Long orderId = orderId(orderNo);
        String validPayload = paymentCallback(paymentNo, amountYuan(PAY_AMOUNT_CENTS));
 
        // 验签不通过:回调被直接拒绝,连审计行都不写,业务状态更不会变。
        when(signatureService.verifyCallbackPayload(any(), any())).thenReturn(false);
        assertThatThrownBy(() -> callbackService.handle(validPayload, "forged-signature"))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("验签失败");
        assertThat(paymentRow(paymentNo)).containsEntry("status", "PREPAY_CREATED");
        assertThat(orderRow(orderId)).containsEntry("order_status", 0);
        assertThat(count("select count(*) from payment_callback_event where provider_request_no = ?", paymentNo))
                .isZero();
 
        // 签名有效但金额被改动:同样不履约,落异常账。
        when(signatureService.verifyCallbackPayload(any(), any())).thenReturn(true);
        callbackService.handle(paymentCallback(paymentNo, "0.01"), "signature");
        assertThat(paymentRow(paymentNo)).containsEntry("status", "PREPAY_CREATED");
        assertThat(orderRow(orderId)).containsEntry("order_status", 0);
        assertThat(openIssueCount("PAYMENT_AMOUNT_MISMATCH", paymentNo)).isEqualTo(1L);
    }
 
    @Test
    void aMerchantWithoutAConfirmedCapabilityCannotCreateAPayableOrder() {
        // 用 JDBC 造一条「进件已通过、能力快照还没确认」的商户:支付链路上不调用通道,但也不放行。
        insertApprovedOnboardingWithoutSnapshot();
 
        assertThatThrownBy(() -> createPayableOrder())
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("待确认");
        assertThat(count("select count(*) from benefit_order where merchant_id = ?", merchantId)).isZero();
    }
 
    @Test
    void theWithdrawalSurfaceIsReadOnlyAndNeverShowsAStateOutsideTheThreeAgreedOnes() {
        // 商户不能自己发起提现:写路由不存在,只有只读的列表与汇总。
        assertThat(registeredMethods("/api/merchant/withdrawals"))
                .as("商户端提现只有 GET 列表")
                .containsExactly("GET");
        assertThat(registeredPatterns().stream().filter(path -> path.startsWith("/api/merchant/withdrawals")).toList())
                .as("商户端不存在提现写路由")
                .allMatch(path -> path.equals("/api/merchant/withdrawals") || path.equals("/api/merchant/withdrawals/summary"));
        // 后台也不能人工审核或随意改状态:提现管理只有查询。
        assertThat(registeredMethods("/api/admin/management/merchant-settlement/withdrawals"))
                .as("后台提现管理只有 GET 查询")
                .containsExactly("GET");
        assertThat(registeredPatterns().stream()
                .filter(path -> path.startsWith("/api/admin/management/merchant-settlement/withdrawals")).toList())
                .as("后台不存在提现写路由")
                .containsExactly("/api/admin/management/merchant-settlement/withdrawals");
 
        // 四种库内状态在商户端只折叠成三种对外状态。
        readyMerchant();
        LocalDate businessDate = someBusinessDate();
        String orderNo = paidVerifiedOrder(businessDate);
        Long orderId = orderId(orderNo);
        aggregation.run(businessDate);
        Withdrawal withdrawal = withdrawalOf(businessDate, orderId);
        assertThat(withdrawalRow(withdrawal.id())).containsEntry("status", "pending");
        assertOnlyWithdrawalStatus("processing");
 
        when(withdrawalGateway.create(any())).thenReturn(sync(Map.of(
                "resp_code", "00000000", "trans_stat", "P", "hf_seq_id", "HF-VIS-" + suffix)));
        withdrawalResultService.submitPending(200);
        assertOnlyWithdrawalStatus("processing");
 
        when(withdrawalGateway.create(any())).thenReturn(sync(Map.of(
                "resp_code", "22000004", "resp_desc", "暂未开通取现权限")));
        jdbc.update("update merchant_withdrawal set status='pending' where id = ?", withdrawal.id());
        withdrawalResultService.submitPending(200);
        assertThat(merchantWithdrawals()).singleElement().satisfies(row -> assertThat(row)
                .containsEntry("status", "failed")
                .containsEntry("reason", "暂未开通取现权限"));
 
        when(withdrawalGateway.create(any())).thenReturn(sync(Map.of(
                "resp_code", "00000000", "trans_stat", "S", "hf_seq_id", "HF-VIS-S-" + suffix)));
        jdbc.update("update merchant_withdrawal set status='pending' where id = ?", withdrawal.id());
        withdrawalResultService.submitPending(200);
        assertOnlyWithdrawalStatus("paid");
    }
 
    // ------------------------------------------------------------------------------------------
    // 生产路径 helper
    // ------------------------------------------------------------------------------------------
 
    /** 每个用例都从真实进件入口把商户做成「可收款」状态,链路起点一致。 */
    private void readyMerchant() {
        onboardIndividualMerchant();
        assertThat(readinessService.requireCollectingMerchant(merchantId, storeId).huifuId()).isEqualTo(huifuId);
    }
 
    /** 走真实进件入口,直到进件 approved、结算卡 approved 且能力快照已确认。 */
    private void onboardIndividualMerchant() {
        Map<String, Object> submitted = settlementService.submitOnboarding(principal, onboardingRequest(), "acceptance-onboarding-" + suffix);
        assertThat(submitted).containsEntry("status", "pending");
        // 商户业务开通是异步的:通道回调(notify_type=W + business_stat=S)才会让进件最终通过。
        String applicationNo = String.valueOf(submitted.get("applicationNo"));
        assertThat(settlementService.handleBusinessOpenCallback(Map.of(
                "req_seq_id", applicationNo + "BO",
                "sub_resp_code", "00000000",
                "sub_resp_desc", "成功",
                "audit_status", "Y",
                "notify_type", "W",
                "reg_result_list", List.of(Map.of("business_stat", "S"))))).isTrue();
 
        Map<String, Object> application = applicationRow();
        assertThat(application).containsEntry("status", "approved");
        onboardingId = ((Number) application.get("id")).longValue();
        huifuId = String.valueOf(application.get("provider_huifu_id"));
        assertThat(huifuId).isNotBlank();
    }
 
    /** 用 JDBC 造一条「进件已通过、能力快照还没确认」的商户:闸口必须挡住支付。 */
    private void insertApprovedOnboardingWithoutSnapshot() {
        when(capabilityGateway.query(any())).thenThrow(new RuntimeException("snapshot must not be queried on the payment path"));
        jdbc.update("""
                insert into merchant_bank_card(merchant_id, store_id, bank_name, branch_name, city_code, card_tail,
                                               card_masked, card_hash, status, idempotency_key, provider_token_no)
                values (?, ?, '中国建设银行', '攀枝花东区支行', '510400', '8888', '**** **** **** 8888', ?, 'approved', ?, ?)
                """, merchantId, storeId, "hash-" + suffix, "acceptance-card-" + suffix, shortReference("T", suffix));
        Long cardId = jdbc.queryForObject("select max(id) from merchant_bank_card", Long.class);
        jdbc.update("""
                insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type,
                                                            subject_name, contact_name, contact_mobile, idempotency_key,
                                                            status, provider_huifu_id, initial_bank_card_id)
                values (?, ?, ?, 'individual', '验收商户负责人', '验收商户负责人', '13900000006', ?, 'approved', ?, ?)
                """, merchantId, storeId, "MO-ACC-" + suffix, "acceptance-onboarding-snapshot-" + suffix,
                shortReference("H", suffix), cardId);
        onboardingId = jdbc.queryForObject("select max(id) from merchant_onboarding_application", Long.class);
        huifuId = shortReference("H", suffix);
    }
 
    private MerchantOnboardingRequest onboardingRequest() {
        return new MerchantOnboardingRequest(
                "individual",
                "验收商户负责人",
                null,
                "510411199001010011",
                "long_term",
                "2020-01-01",
                null,
                null,
                null,
                null,
                "四川省攀枝花市东区",
                "510000",
                "510400",
                "510402",
                "攀枝花市东区验收路 1 号",
                null,
                null,
                null,
                null,
                null,
                null,
                "https://example.com/acceptance-id-front.png",
                "https://example.com/acceptance-id-back.png",
                "攀枝花市东区验收路 1 号",
                "acceptance-" + suffix + "@example.com",
                "验收商户负责人",
                "13900000006",
                "6217001234567891234",
                "510000",
                "510400",
                BRANCH_CODE,
                "https://example.com/acceptance-card-front.png",
                null);
    }
 
    /** 下单 → 支付预下单 → 验签回调 → 商户核销,并把这笔核销挪进指定业务日的窗口。 */
    private String paidVerifiedOrder(LocalDate businessDate) {
        String orderNo = paidOrder();
        merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "acceptance"),
                verifyKey(orderNo));
        verifiedOn(orderId(orderNo), businessDate);
        return orderNo;
    }
 
    /** 下单 → 支付预下单 → 验签回调:订单已支付、未核销,可以退款。 */
    private String paidOrder() {
        String orderNo = createPayableOrder();
        String paymentNo = String.valueOf(paymentService.beginMiniProgramPayment(
                orderNo, talentId, 1, "acceptance-payment-" + suffix + "-" + orderNo).get("paymentNo"));
        callbackService.handle(paymentCallback(paymentNo, amountYuan(PAY_AMOUNT_CENTS)), "signature");
        assertThat(orderRow(orderNo)).containsEntry("order_status", 1).containsEntry("verify_status", 0);
        return orderNo;
    }
 
    /** 核销幂等键按订单区分:核销键在商户维度唯一,重复用同一个键会被判成「已用于其他订单」。 */
    private String verifyKey(String orderNo) {
        return "acceptance-verify-" + suffix + "-" + orderNo;
    }
 
    private String createPayableOrder() {
        when(paymentGateway.createMiniProgramPayment(any())).thenReturn(sync(Map.of(
                "trans_stat", "S", "resp_code", "00000000", "pay_info", "{\"appId\":\"wxtest\"}")));
        Map<String, Object> created = appOrderPortalService.create(talentId,
                new AppOrderCreateRequest(merchantId, productId, null), "acceptance-order-" + suffix + "-" + System.nanoTime());
        String orderNo = String.valueOf(created.get("orderNo"));
        // 工单 01 的已知遗留:AppOrderCreateRequest 不携带门店,订单不写 store_id,能力校验只能到商户维度。
        // 日汇总按「商户 + 门店」出账,所以这里补上门店(订单已经记下收款门店 collect_store_id)。
        jdbc.update("update benefit_order set store_id = collect_store_id, order_type = 'GROUP_BUY' where order_no = ?", orderNo);
        assertThat(orderRow(orderNo)).containsEntry("pay_amount", PAY_AMOUNT_CENTS);
        return orderNo;
    }
 
    private Refund requestRefundUnderWay(String orderNo) {
        when(refundGateway.createRefund(any())).thenReturn(sync(Map.of(
                "trans_stat", "P", "resp_code", "00000000", "hf_seq_id", "HF-R-P-" + suffix)));
        Map<String, Object> requested = refundService.request(orderNo,
                new OrderRefundRequest(BigDecimal.valueOf(PAY_AMOUNT_CENTS, 2), "acceptance", "tester"));
        return new Refund(String.valueOf(requested.get("refundNo")));
    }
 
    // ------------------------------------------------------------------------------------------
    // 报文 builder
    // ------------------------------------------------------------------------------------------
 
    /** 回调报文里的金额是两位小数的元,和通道一致;整数分换算一次,避免用 double 表示金额。 */
    private String paymentCallback(String paymentNo, String transAmt) {
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("resp_code", "00000000");
        data.put("resp_desc", "成功");
        data.put("trans_stat", "S");
        data.put("req_seq_id", paymentNo);
        data.put("huifu_id", huifuId);
        data.put("trans_amt", transAmt);
        data.put("hf_seq_id", "HF-PAY-" + paymentNo);
        return JSON.toJSONString(data);
    }
 
    /** 整数分 → 斗拱要求的两位小数元。 */
    private String amountYuan(long cents) {
        return BigDecimal.valueOf(cents, 2).toPlainString();
    }
 
    private String refundCallback(String refundNo, String status, String code) {
        return JSON.toJSONString(Map.of(
                "resp_code", code,
                "resp_desc", "退款回调",
                "trans_stat", status,
                "req_seq_id", refundNo));
    }
 
    private Map<String, Object> withdrawalNotification(String status, String code, String description) {
        return Map.of(
                "sub_resp_code", code,
                "sub_resp_desc", description,
                "trans_status", status,
                "msg_type", "02");
    }
 
    private Map<String, Object> sync(Map<String, Object> data) {
        return Map.of("data", data);
    }
 
    /** 交易确认成功:钱从通道「待确认」分配给收款商户。 */
    private HuifuDelayAccountGateway.HuifuDelayConfirmation delayConfirmed() {
        return new HuifuDelayAccountGateway.HuifuDelayConfirmation(
                HuifuDelayAccountGateway.HuifuDelayConfirmation.Outcome.SUCCEEDED,
                "00000000", "交易成功", "0.00",
                BigDecimal.valueOf(PAY_AMOUNT_CENTS, 2).toPlainString(), "HF-DELAY-" + suffix);
    }
 
    // ------------------------------------------------------------------------------------------
    // 查询 helper
    // ------------------------------------------------------------------------------------------
 
    private Map<String, Object> orderRow(String orderNo) {
        return jdbc.queryForMap("select * from benefit_order where order_no = ?", orderNo);
    }
 
    private Map<String, Object> orderRow(Long orderId) {
        return jdbc.queryForMap("select * from benefit_order where id = ?", orderId);
    }
 
    private Long orderId(String orderNo) {
        return jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, orderNo);
    }
 
    private Map<String, Object> paymentRow(String paymentNo) {
        return jdbc.queryForMap("select * from payment_transaction where payment_no = ?", paymentNo);
    }
 
    private Map<String, Object> withdrawalRow(Long withdrawalId) {
        return jdbc.queryForMap("select * from merchant_withdrawal where id = ?", withdrawalId);
    }
 
    private Map<String, Object> applicationRow() {
        return jdbc.queryForMap("""
                select * from merchant_onboarding_application
                where merchant_id = ? and store_id = ? and initial_bank_card_id is not null
                order by id desc limit 1
                """, merchantId, storeId);
    }
 
    private Map<String, Object> cardRow() {
        return jdbc.queryForMap("""
                select * from merchant_bank_card where merchant_id = ? and store_id = ? order by id desc limit 1
                """, merchantId, storeId);
    }
 
    private Map<String, Object> capabilityRow() {
        return jdbc.queryForMap("select * from merchant_onboarding_application where id = ?", onboardingId);
    }
 
    private String onboardingStatus() {
        return String.valueOf(settlementService.onboardingStatus(principal).get("status"));
    }
 
    private List<Map<String, Object>> merchantWithdrawals() {
        PageResult<Map<String, Object>> page = settlementService.withdrawals(principal, 1, 50);
        return page.records();
    }
 
    /** 本商户只有一条提现记录,且商户端读到的状态就是期望的那一种。 */
    private void assertOnlyWithdrawalStatus(String status) {
        assertThat(merchantWithdrawals()).singleElement()
                .satisfies(row -> assertThat(row).containsEntry("status", status));
    }
 
    /** 某个业务日里包含指定订单的那条提现记录。 */
    private Withdrawal withdrawalOf(LocalDate businessDate, Long orderId) {
        Map<String, Object> row = jdbc.queryForMap("""
                select w.id, w.withdrawal_no, w.amount_cents
                from merchant_withdrawal w
                join merchant_withdrawal_order a on a.withdrawal_id = w.id and a.released_at is null
                where w.merchant_id = ? and w.withdrawal_date = ? and a.order_id = ?
                """, merchantId, businessDate.toString().replace("-", ""), orderId);
        return new Withdrawal(((Number) row.get("id")).longValue(), String.valueOf(row.get("withdrawal_no")),
                ((Number) row.get("amount_cents")).longValue());
    }
 
    private Integer withdrawalCount(LocalDate businessDate) {
        return jdbc.queryForObject("""
                select count(*) from merchant_withdrawal where merchant_id = ? and withdrawal_date = ?
                """, Integer.class, merchantId, businessDate.toString().replace("-", ""));
    }
 
    private List<Long> sourceOrderIds(Long withdrawalId) {
        return jdbc.queryForList("select order_id from merchant_withdrawal_order where withdrawal_id = ?",
                Long.class, withdrawalId);
    }
 
    private Object releasedAt(Long withdrawalId, Long orderId) {
        return jdbc.queryForObject("""
                select released_at from merchant_withdrawal_order where withdrawal_id = ? and order_id = ?
                """, Object.class, withdrawalId, orderId);
    }
 
    private Integer activeVerifyRecords(Long orderId) {
        return jdbc.queryForObject("select count(*) from verify_record where order_id = ? and status = 1",
                Integer.class, orderId);
    }
 
    private Long openIssueCount(String type, String businessNo) {
        return jdbc.queryForObject("""
                select count(*) from payment_reconciliation_issue
                where issue_type = ? and business_no = ? and status = 'open'
                """, Long.class, type, businessNo);
    }
 
    private long count(String sql, Object... args) {
        Long value = jdbc.queryForObject(sql, Long.class, args);
        return value == null ? 0 : value;
    }
 
    private Set<String> registeredMethods(String path) {
        return handlerMapping.getHandlerMethods().entrySet().stream()
                .filter(entry -> entry.getKey().getPathPatternsCondition() != null
                        && entry.getKey().getPathPatternsCondition().getPatternValues().contains(path))
                .flatMap(entry -> entry.getKey().getMethodsCondition().getMethods().stream())
                .map(Enum::name)
                .collect(Collectors.toSet());
    }
 
    private Set<String> registeredPatterns() {
        return handlerMapping.getHandlerMethods().keySet().stream()
                .filter(info -> info.getPathPatternsCondition() != null)
                .flatMap(info -> info.getPathPatternsCondition().getPatternValues().stream())
                .collect(Collectors.toSet());
    }
 
    // ------------------------------------------------------------------------------------------
    // 造数与时间 helper
    // ------------------------------------------------------------------------------------------
 
    /** 业务日取足够远的未来日期,避开共享测试库里别的用例留下的核销时间。 */
    private LocalDate someBusinessDate() {
        return LocalDate.now(ZoneId.of("Asia/Shanghai")).plusDays(BUSINESS_DATE_OFFSET.getAndIncrement());
    }
 
    private void verifiedOn(Long orderId, LocalDate businessDate) {
        jdbc.update("""
                update verify_record set verify_time = ?
                where order_id = ? and status = 1
                """, LocalDateTime.of(businessDate, java.time.LocalTime.of(10, 0)), orderId);
    }
 
    private void ageProcessing(Long withdrawalId) {
        jdbc.update("update merchant_withdrawal set updated_at = date_sub(now(), interval 30 minute) where id = ?",
                withdrawalId);
    }
 
    /** 通道标识限长 32,测试里统一截短。 */
    private String shortReference(String prefix, String suffix) {
        return prefix + suffix.substring(Math.max(0, suffix.length() - 8));
    }
 
    private record Withdrawal(Long id, String withdrawalNo, long amountCents) { }
 
    private record Refund(String refundNo) { }
}