yeg
2026-09-17 e71919933b57c4284e19cad12b4791ef52a58c8a
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
package com.talentcard.server.business.payment;
 
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.when;
 
import java.math.BigDecimal;
import java.util.Map;
 
import com.talentcard.server.business.PageResult;
import com.talentcard.server.business.order.OrderRefundRequest;
import com.talentcard.server.business.payment.HuifuRefundService.StrandedRefundOutcome;
import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway;
import com.talentcard.server.business.payment.huifu.HuifuRefundGateway;
import com.talentcard.server.business.portal.UsageRecordPortalService;
import com.talentcard.server.common.BusinessException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
 
/**
 * Seam: money the provider collected for an order that is already cancelled has exactly one right
 * destination — back to the payer — and the refund has to be visible in local state.
 *
 * <p>The two provider seams (payment query and refund submission) are mocked; the reconciliation,
 * settlement, fund boundary and refund services all run for real against the shared MySQL schema.
 */
@SpringBootTest
@ActiveProfiles("test")
class StrandedPaymentRefundTests {
 
    @Autowired
    private JdbcTemplate jdbc;
 
    @Autowired
    private PaymentReconciliationService reconciliationService;
 
    @Autowired
    private HuifuRefundService refundService;
 
    @Autowired
    private PaymentIssueRecorder issues;
 
    @Autowired
    private StrandedPaymentRefundRetry retry;
 
    @Autowired
    private UsageRecordPortalService usageRecordPortalService;
 
    @MockitoBean
    private HuifuMiniProgramPaymentGateway paymentGateway;
 
    @MockitoBean
    private HuifuRefundGateway refundGateway;
 
    private Long talentId;
    private Long merchantId;
    private String huifuId;
    private String suffix;
    private long sequence;
 
    @BeforeEach
    void prepareData() {
        suffix = String.valueOf(System.nanoTime());
        jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)",
                "stranded-refund-openid-" + suffix, "Stranded refund user", "13900000005");
        talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class);
        jdbc.update("insert into merchant(merchant_name, contact_mobile, address, status, deleted) values (?, ?, ?, 1, 0)",
                "Stranded refund merchant " + suffix, "08120000004", "Test address");
        merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class);
        huifuId = "H" + suffix.substring(Math.max(0, suffix.length() - 8));
        sequence = 0;
    }
 
    @Test
    void aCancelledOrderWhosePaymentWasCollectedIsRefundedToThePayer() {
        String orderNo = cancelledOrderWithCollectedPayment("CANCEL_UNCONFIRMED", 2000L);
        providerAcceptsRefunds();
 
        int refunded = retry.retry(1);
 
        assertThat(refunded).as("这一轮应该发起一笔悬账退款").isEqualTo(1);
        assertThat(jdbc.queryForMap("select paid_amount, refund_amount, refund_status from order_refund where order_no = ?", orderNo))
                .as("悬账要真的退回付款人,而且是全额")
                .containsEntry("paid_amount", 2000L)
                .containsEntry("refund_amount", 2000L)
                .containsEntry("refund_status", 2);
        assertThat(openIssueCount("PAYMENT_ORDER_CANCELLED", paymentNo(orderNo)))
                .as("钱有了唯一的出口,问题记录就该结掉")
                .isZero();
        assertThat(openIssueCount("STRANDED_REFUND_REJECTED", paymentNo(orderNo))).isZero();
        assertThat(((Number) talentOrderRecord(orderNo).get("refundStatus")).intValue())
                .as("人才端的「使用记录」要知道钱已经退回来了(页面渲染成「已退款」)")
                .isEqualTo(2);
    }
 
    @Test
    void aRefundTheProviderRejectsEscalatesToAHumanInsteadOfBeingSwallowed() {
        String orderNo = cancelledOrderWithCollectedPayment("CANCEL_UNCONFIRMED", 2000L);
        when(refundGateway.createRefund(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "F", "sub_resp_code", "20000001", "sub_resp_desc", "基本户余额不足")));
 
        retry.retry(1);
 
        assertThat(openIssueCount("PAYMENT_ORDER_CANCELLED", paymentNo(orderNo)))
                .as("退不回去的悬账必须留在账上,绝不能静默吞掉")
                .isEqualTo(1L);
        assertThat(openIssueCount("STRANDED_REFUND_REJECTED", paymentNo(orderNo)))
                .as("通道拒绝要升级成一条能被人看见的告警")
                .isEqualTo(1L);
        assertThat(count("select count(*) from order_refund where order_no = ? and refund_status = 2", orderNo))
                .as("通道拒绝就是没有退成,不能记成已退款")
                .isZero();
    }
 
    /**
     * 本地已关闭、但通道确认收过钱的悬账:旧的支付键可见性只认已成功流水,这类钱连支付键都看不到,
     * 于是退款无从发起(D19 的回归)。
     */
    @Test
    void aPaymentClosedLocallyButConfirmedByTheProviderCanStillBeRefunded() {
        String orderNo = cancelledOrder("CLOSED", 2000L);
        String paymentNo = paymentNo(orderNo);
        issues.issue("PAYMENT_ORDER_CANCELLED", paymentNo, orderNo, "cancelled", "provider confirmed QUERY", 2000L,
                "Payment succeeded after the local order was cancelled");
        providerAcceptsRefunds();
 
        assertThat(refundService.refundStranded(orderNo)).isEqualTo(StrandedRefundOutcome.SUBMITTED);
 
        assertThat(jdbc.queryForMap("""
                select provider_original_req_seq_id, refund_amount from order_refund where order_no = ?
                """, orderNo))
                .as("退款必须定位到那笔已关闭的支付流水,否则通道侧无法原路退回")
                .containsEntry("provider_original_req_seq_id", paymentNo)
                .containsEntry("refund_amount", 2000L);
    }
 
    @Test
    void theNarrowEntryRefusesAnythingThatIsNotAStrandedPayment() {
        // 订单没取消:不能按悬账退款,否则会绕过普通退款路径的前置条件。
        String liveOrderNo = paidOrder(2000L, "SUCCEEDED");
        assertThat(refundService.refundStranded(liveOrderNo)).isEqualTo(StrandedRefundOutcome.NOT_APPLICABLE);
 
        // 订单已取消,但通道从没确认收过钱(流水还是未定局):没有可退的悬账。
        String undecidedNo = cancelledOrder("PREPAY_CREATED", 2000L);
        assertThat(refundService.refundStranded(undecidedNo)).isEqualTo(StrandedRefundOutcome.NOT_APPLICABLE);
 
        // 已经退过:不能重复退款。
        String refundingNo = cancelledOrder("CANCEL_UNCONFIRMED", 2000L);
        Long orderId = jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, refundingNo);
        jdbc.update("""
                insert into order_refund(refund_no, order_id, order_no, talent_id, merchant_id, order_type,
                                         paid_amount, refund_amount, refundable_amount, refund_status,
                                         provider, reason, operator_name)
                values (?, ?, ?, ?, ?, 'GROUP_BUY', 2000, 2000, 0, 1, 'HUIFU', '已受理', 'system')
                """, "R-EXISTING-" + suffix, orderId, refundingNo, talentId, merchantId);
        assertThat(refundService.refundStranded(refundingNo))
                .as("资金边界先锁订单行,再断言没有生效中的退款:在途退款不会被退第二次")
                .isEqualTo(StrandedRefundOutcome.NOT_APPLICABLE);
    }
 
    @Test
    void aPartialRefundLeavesTheRemainderRefundableWhileOverRefundingIsStillRefused() {
        String orderNo = paidOrder(10000L, "SUCCEEDED");
        providerAcceptsRefunds();
 
        refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("40.00"), "部分退款", "admin"));
 
        assertThat(jdbc.queryForMap("select refund_amount, refundable_amount, refund_status from order_refund where order_no = ?", orderNo))
                .containsEntry("refund_amount", 4000L)
                .containsEntry("refundable_amount", 6000L)
                .containsEntry("refund_status", 2);
 
        assertThatThrownBy(() -> refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("70.00"), "超额", "admin")))
                .as("超额退款仍被拒")
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("Invalid refund amount");
 
        refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("60.00"), "退剩余", "admin"));
 
        assertThat(count("select coalesce(sum(refund_amount), 0) from order_refund where order_no = ? and refund_status = 2", orderNo))
                .as("首笔部分退款之后剩余金额必须还退得掉(旧规则下它永远退不了)")
                .isEqualTo(10000L);
        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo))
                .as("全额退完订单才退出")
                .isEqualTo(2);
    }
 
    @Test
    void moneyStillInFlightCountsAgainstTheRefundableAmount() {
        String orderNo = paidOrder(10000L, "SUCCEEDED");
        when(refundGateway.createRefund(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "P", "resp_code", "00000100", "sub_resp_desc", "交易处理中")));
 
        refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("40.00"), "部分退款", "admin"));
 
        assertThat(jdbc.queryForObject("select refund_status from order_refund where order_no = ?", Integer.class, orderNo))
                .as("通道还在处理就停在退款中")
                .isEqualTo(1);
        assertThatThrownBy(() -> refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("70.00"), "超额", "admin")))
                .as("在途金额也要算进可退额度,否则同一笔钱会被退两次")
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("Invalid refund amount");
 
        refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("60.00"), "退剩余", "admin"));
 
        assertThat(count("select count(*) from order_refund where order_no = ?", orderNo)).isEqualTo(2);
        assertThatThrownBy(() -> refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("1.00"), "再多退一分", "admin")))
                .as("实付金额退满之后一分钱都不能再退")
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("Invalid refund amount");
    }
 
    /** 一笔「订单已取消 + 通道确认收款」的悬账,按真实链路构造:先取消,再由对账把收款事实落下来。 */
    private String cancelledOrderWithCollectedPayment(String paymentStatus, long amount) {
        String orderNo = cancelledOrder(paymentStatus, amount);
        when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "S", "resp_code", "00000000", "trans_amt", "20.00",
                "huifu_id", huifuId, "org_hf_seq_id", "HF-STRANDED-" + suffix)));
 
        reconciliationService.confirmPendingPayments(orderNo);
 
        String paymentNo = paymentNo(orderNo);
        assertThat(openIssueCount("PAYMENT_ORDER_CANCELLED", paymentNo))
                .as("构造前提:对账发现了「取消后才收到钱」")
                .isEqualTo(1L);
        isolateIssueWindow(paymentNo);
        return orderNo;
    }
 
    /**
     * 消费者每轮按 {@code detected_at} 取最老的一批,而测试库是共享且不清理的:把本用例这条推到最老、
     * 把别的悬账推到窗口之外,这一轮就只会处理它自己(docs/agents/testing.md 的做法)。
     */
    private void isolateIssueWindow(String paymentNo) {
        jdbc.update("""
                update payment_reconciliation_issue set detected_at = now() + interval 30 day
                where issue_type = 'PAYMENT_ORDER_CANCELLED' and status = 'open' and business_no <> ?
                """, paymentNo);
        jdbc.update("""
                update payment_reconciliation_issue set detected_at = now() - interval 30 day
                where issue_type = 'PAYMENT_ORDER_CANCELLED' and business_no = ?
                """, paymentNo);
    }
 
    private String cancelledOrder(String paymentStatus, long amount) {
        String orderNo = "STRANDED-" + suffix + "-" + (++sequence);
        jdbc.update("""
                insert into benefit_order(order_no, talent_id, merchant_id, order_type, order_amount, base_pay_amount,
                                          pay_amount, order_status, verify_status, price_version, collect_huifu_id,
                                          product_name_snapshot)
                values (?, ?, ?, 'GROUP_BUY', ?, ?, ?, 2, 0, 1, ?, '悬账测试订单')
                """, orderNo, talentId, merchantId, amount, amount, amount, huifuId);
        Long orderId = jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, orderNo);
        jdbc.update("""
                insert into payment_transaction(payment_no, provider, order_id, order_no, talent_id, merchant_id,
                                                provider_huifu_id, amount, status, idempotency_key, created_at)
                values (?, 'HUIFU', ?, ?, ?, ?, ?, ?, ?, ?, now() - interval 10 minute)
                """, paymentNo(orderNo), orderId, orderNo, talentId, merchantId, huifuId, amount, paymentStatus,
                "stranded-payment-" + suffix + "-" + sequence);
        return orderNo;
    }
 
    private String paidOrder(long amount, String paymentStatus) {
        String orderNo = "STRANDED-PAID-" + suffix + "-" + (++sequence);
        jdbc.update("""
                insert into benefit_order(order_no, talent_id, merchant_id, order_type, order_amount, base_pay_amount,
                                          pay_amount, order_status, verify_status, price_version, collect_huifu_id,
                                          product_name_snapshot)
                values (?, ?, ?, 'GROUP_BUY', ?, ?, ?, 1, 0, 1, ?, '部分退款测试订单')
                """, orderNo, talentId, merchantId, amount, amount, amount, huifuId);
        Long orderId = jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, orderNo);
        jdbc.update("""
                insert into payment_transaction(payment_no, provider, order_id, order_no, talent_id, merchant_id,
                                                provider_huifu_id, amount, status, idempotency_key, paid_at)
                values (?, 'HUIFU', ?, ?, ?, ?, ?, ?, ?, ?, now())
                """, paymentNo(orderNo), orderId, orderNo, talentId, merchantId, huifuId, amount, paymentStatus,
                "paid-payment-" + suffix + "-" + sequence);
        return orderNo;
    }
 
    private String paymentNo(String orderNo) {
        return "P-" + orderNo;
    }
 
    private void providerAcceptsRefunds() {
        when(refundGateway.createRefund(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "S", "resp_code", "00000000", "sub_resp_desc", "交易成功", "hf_seq_id", "HF-STRANDED-R-" + suffix)));
    }
 
    private Map<String, Object> providerResponse(Map<String, Object> data) {
        return Map.of("data", data);
    }
 
    /** 人才端「使用记录」为该订单给出的那一条:页面按它的 refundStatus 渲染「已退款」。 */
    private Map<String, Object> talentOrderRecord(String orderNo) {
        PageResult<Map<String, Object>> page = usageRecordPortalService.list(talentId, null, 1, 50);
        return page.records().stream()
                .filter(record -> orderNo.equals(String.valueOf(record.get("businessId"))))
                .findFirst()
                .orElseThrow(() -> new AssertionError("Order " + orderNo + " is missing from the talent order list"));
    }
 
    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;
    }
}