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
package com.talentcard.server.business.payment;
 
import com.talentcard.server.business.BusinessIdGenerator;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.MoneyUtils;
import com.talentcard.server.business.order.OrderFundBoundaryService;
import com.talentcard.server.business.order.OrderRefundRequest;
import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException;
import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties;
import com.talentcard.server.business.payment.huifu.HuifuRefundCommand;
import com.talentcard.server.business.payment.huifu.HuifuRefundGateway;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
 
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
 
@Service
public class HuifuRefundService {
 
    private static final String PROVIDER = "HUIFU";
    private static final DateTimeFormatter REQUEST_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
 
    private final JdbcTemplate jdbc;
    private final TransactionTemplate transactions;
    private final HuifuRefundGateway gateway;
    private final HuifuPaymentProperties huifuPaymentProperties;
    private final OrderFundBoundaryService orderFundBoundary;
 
    public HuifuRefundService(JdbcTemplate jdbc, TransactionTemplate transactions, HuifuRefundGateway gateway, HuifuPaymentProperties huifuPaymentProperties, OrderFundBoundaryService orderFundBoundary) {
        this.jdbc = jdbc;
        this.transactions = transactions;
        this.gateway = gateway;
        this.huifuPaymentProperties = huifuPaymentProperties;
        this.orderFundBoundary = orderFundBoundary;
    }
 
    public Map<String, Object> request(String orderNo, OrderRefundRequest request) {
        PreparedRefund prepared = transactions.execute(status -> prepare(orderNo, request));
        if (prepared == null) throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Refund initialization failed");
 
        Map<String, Object> response;
        try {
            response = gateway.createRefund(new HuifuRefundCommand(
                    prepared.refundNo(), prepared.huifuId(), prepared.paymentNo(), prepared.originalProviderNo(),
                    prepared.originalRequestDate(), prepared.refundAmount()));
        } catch (HuifuPaymentGatewayException exception) {
            if (!exception.isOutcomeUnknown()) {
                markFailed(prepared.refundNo(), prepared.orderId(), "GATEWAY_REJECTED", exception.getMessage());
                throw new BusinessException(ErrorCode.CONFLICT, "Refund request was rejected");
            }
            return detail(prepared.refundNo());
        } catch (BusinessException exception) {
            markFailed(prepared.refundNo(), prepared.orderId(), "LOCAL_ERROR", exception.getMessage());
            throw exception;
        } catch (RuntimeException exception) {
            return detail(prepared.refundNo());
        }
 
        ProviderResult result = ProviderResult.from(response);
        if (result.success()) {
            markSucceeded(prepared, result);
        } else if (result.failed()) {
            markFailed(prepared.refundNo(), prepared.orderId(), result.code(), result.message());
            throw new BusinessException(ErrorCode.CONFLICT, "Refund request was rejected");
        } else {
            updateProviderResult(prepared.refundNo(), result);
        }
        return detail(prepared.refundNo());
    }
 
    public Map<String, Object> query(String refundNo) {
        Map<String, Object> refund = detail(refundNo);
        if (number(refund.get("refundStatus")) != 1) return refund;
        HuifuRefundCommand command = new HuifuRefundCommand(
                refundNo,
                refundHuifuId(refund.get("providerHuifuId")),
                text(refund.get("providerOriginalReqSeqId")),
                text(refund.get("providerOriginalNo")),
                text(refund.get("providerOriginalReqDate")),
                number(refund.get("refundAmount")));
        try {
            ProviderResult result = ProviderResult.from(gateway.queryRefund(command, text(refund.get("providerRefundNo"))));
            if (result.success()) markSucceeded(new PreparedRefund(refundNo, number(refund.get("orderId")), number(refund.get("refundAmount"))), result);
            else if (result.failed()) markFailed(refundNo, number(refund.get("orderId")), result.code(), result.message());
            else updateProviderResult(refundNo, result);
        } catch (HuifuPaymentGatewayException ignored) {
            // Keep the refund pending when the provider outcome is unknown.
        }
        return detail(refundNo);
    }
 
    /** 刚提交的退款通道必然还在处理,留一点时间再去问,避免用户一打开页面就打通道。 */
    private static final int SUBMISSION_GRACE_SECONDS = 20;
    /** 一轮最多收敛几笔在途退款:正常用户同一时刻只有一笔,列表读路径不该被它拖长。 */
    public static final int PENDING_CONFIRM_LIMIT = 2;
 
    /**
     * 把这位人才名下还在途的退款各自向通道确认一次。
     *
     * <p>退款提交时通道多半只回「处理中」,最终结果只有通道知道;退款通知又会遇到和支付通知一样的
     * 问题——本地或内网环境收不到。于是在读订单/读订单列表之前问一次,把「退款中」尽快收敛成通道的
     * 最终答案,而不是等对账任务的下一个窗口(最长十分钟)。已经落定的退款连查都不查。
     *
     * <p>在自己的事务里执行,且必须是调用方事务里的**第一条**语句:调用方的事务要能读到收敛后的状态,
     * 而 MySQL 的一致性快照是在该事务的第一条读语句上定下的。单笔查询失败只跳过这一笔——读路径上的
     * 尽力收敛,查不到结果不该让订单列表打不开,剩下的交给对账任务和下一次读。
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public int confirmPendingRefunds(Long talentId, int limit) {
        return confirmPendingRefunds("o.talent_id", talentId, limit);
    }
 
    /** 同上,按商户归属收敛:商户端订单列表读之前调用,免得商户看到的退款状态比人才端晚几分钟。 */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public int confirmPendingRefundsOfMerchant(Long merchantId, int limit) {
        return confirmPendingRefunds("o.merchant_id", merchantId, limit);
    }
 
    private int confirmPendingRefunds(String ownerColumn, Long ownerId, int limit) {
        if (ownerId == null) return 0;
        List<Map<String, Object>> pending = rows("""
                select r.refund_no
                from order_refund r
                join benefit_order o on o.id = r.order_id
                where r.deleted = 0 and r.provider = ? and r.refund_status = 1
                  and o.deleted = 0 and %s = ?
                  and r.created_at <= date_sub(now(), interval ? second)
                order by r.id desc
                limit ?
                """.formatted(ownerColumn), PROVIDER, ownerId, SUBMISSION_GRACE_SECONDS, Math.max(1, limit));
        for (Map<String, Object> row : pending) {
            try {
                query(text(row.get("refundNo")));
            } catch (RuntimeException ignored) {
                // 这一笔这次问不到,保持在途;对账任务与下一次读会再来。
            }
        }
        return pending.size();
    }
 
    public Map<String, Object> detail(String refundNo) {
        List<Map<String, Object>> rows = rows("""
                select r.id, r.order_id, r.refund_no, r.order_no, r.order_type, r.paid_amount, r.refund_amount,
                       r.refundable_amount, r.refund_status, r.provider, r.provider_refund_no, r.provider_huifu_id,
                       r.provider_original_no, r.provider_original_req_seq_id, r.provider_original_req_date,
                       r.provider_response_code, r.provider_response_message, r.reason, r.operator_name, r.refund_time
                from order_refund r
                where r.deleted = 0 and r.refund_no = ?
                """, refundNo);
        if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "Refund record not found");
        return rows.get(0);
    }
 
    private PreparedRefund prepare(String orderNo, OrderRefundRequest request) {
        List<Map<String, Object>> orders = rows("""
                select o.id, o.order_no, o.talent_id, o.card_id, o.benefit_id, o.merchant_id, o.order_type,
                       o.pay_amount, o.order_status, o.verify_status,
                       p.payment_no, p.provider_transaction_no, p.created_at as payment_created_at,
                       coalesce(p.provider_huifu_id, o.collect_huifu_id) as huifu_id
                from benefit_order o
                left join payment_transaction p on p.order_id = o.id and p.provider = ? and p.status = 'SUCCEEDED'
                where o.deleted = 0 and o.order_no = ?
                order by p.id desc
                limit 1 for update
                """, PROVIDER, orderNo);
        if (orders.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "Order not found");
        Map<String, Object> order = orders.get(0);
        // 支付、核销、退款、提现锁定都按订单串行化:锁住订单行之后才判断能不能退款。
        OrderFundBoundaryService.LockedOrder locked = orderFundBoundary.lock(number(order.get("id")));
        locked.requireRefundable();
        if (locked.verifyStatus() == 1 && !"STORE_CONSUME".equals(text(order.get("orderType")))) {
            throw new BusinessException(ErrorCode.CONFLICT, "Verified orders cannot be refunded");
        }
 
        Integer active = jdbc.queryForObject("select count(*) from order_refund where deleted = 0 and order_id = ? and refund_status in (1,2)", Integer.class, order.get("id"));
        if (active != null && active > 0) throw new BusinessException(ErrorCode.CONFLICT, "Refund has already been submitted for this order");
 
        long paidAmount = number(order.get("payAmount"));
        long successfulRefund = refundedAmount(number(order.get("id")));
        long refundableAmount = Math.max(paidAmount - successfulRefund, 0);
        long requestedAmount = MoneyUtils.yuanToCent(request.refundAmount());
        long refundAmount = requestedAmount > 0 ? requestedAmount : refundableAmount;
        if (refundAmount <= 0 || refundAmount > refundableAmount) throw new BusinessException(ErrorCode.BAD_REQUEST, "Invalid refund amount");
 
        String paymentNo = text(order.get("paymentNo"));
        String providerNo = text(order.get("providerTransactionNo"));
        if (paymentNo.isBlank() && providerNo.isBlank()) throw new BusinessException(ErrorCode.CONFLICT, "Payment transaction is not ready for refund");
        String huifuId = refundHuifuId(order.get("huifuId"));
        String refundNo = BusinessIdGenerator.next("R");
        String requestDate = requestDate(order.get("paymentCreatedAt"));
        jdbc.update("""
                insert into order_refund(refund_no, order_id, order_no, talent_id, card_id, benefit_id, merchant_id,
                                         order_type, paid_amount, refund_amount, refundable_amount, refund_status,
                                         provider, provider_huifu_id, provider_original_no, provider_original_req_seq_id, provider_original_req_date,
                                         reason, operator_name)
                values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)
                """,
                refundNo, order.get("id"), order.get("orderNo"), order.get("talentId"), order.get("cardId"),
                order.get("benefitId"), order.get("merchantId"), order.get("orderType"), paidAmount, refundAmount,
                refundableAmount - refundAmount, PROVIDER, huifuId, providerNo, paymentNo, requestDate, request.reason(), request.operatorName());
        // 退款受理成功即刻生效:在结果确定前订单不再可核销,也不参与提现汇总。
        orderFundBoundary.markRefundInProgress(number(order.get("id")));
        return new PreparedRefund(refundNo, number(order.get("id")), huifuId, paymentNo, providerNo, requestDate, refundAmount);
    }
 
    private void markSucceeded(PreparedRefund prepared, ProviderResult result) {
        transactions.executeWithoutResult(status -> {
            // 通道退款流水号只在发起退款的那次回执里有;退款查询的回执不带它。这里必须保留已有的号,
            // 否则查单确认会把唯一的通道凭证覆盖成空,退款排查和对账就失去定位依据。
            jdbc.update("""
                    update order_refund
                    set refund_status = 2, provider_refund_no = coalesce(nullif(?, ''), provider_refund_no),
                        provider_response_code = ?, provider_response_message = ?, refund_time = now()
                    where refund_no = ? and refund_status = 1
                    """, result.providerNo(), result.code(), result.message(), prepared.refundNo());
            jdbc.update("""
                    update benefit_order o
                    set o.order_status = 2
                    where o.id = ? and o.order_status = 1
                      and o.pay_amount <= (select coalesce(sum(r.refund_amount), 0) from order_refund r where r.order_id = o.id and r.refund_status = 2 and r.deleted = 0)
                    """, prepared.orderId());
            orderFundBoundary.markRefunded(prepared.orderId());
        });
    }
 
    /** 退款失败没有资金变动,订单回到未退款状态(可再次核销或再次退款)。 */
    private void markFailed(String refundNo, long orderId, String code, String message) {
        transactions.executeWithoutResult(status -> {
            int failed = jdbc.update("update order_refund set refund_status = 3, provider_response_code = ?, provider_response_message = ? where refund_no = ? and refund_status = 1", code, message, refundNo);
            // 只有本事务真的把这笔退款置为失败,才把订单退回未退款状态;否则会覆盖并发的退款成功。
            if (failed == 1) orderFundBoundary.markRefundFailed(orderId);
        });
    }
 
    private void updateProviderResult(String refundNo, ProviderResult result) {
        jdbc.update("update order_refund set provider_refund_no = coalesce(?, provider_refund_no), provider_response_code = ?, provider_response_message = ? where refund_no = ? and refund_status = 1", result.providerNo(), result.code(), result.message(), refundNo);
    }
 
    private long refundedAmount(long orderId) {
        Long total = jdbc.queryForObject("select coalesce(sum(refund_amount), 0) from order_refund where deleted = 0 and refund_status = 2 and order_id = ?", Long.class, orderId);
        return total == null ? 0 : total;
    }
 
    /**
     * The merchant a refund must be requested from: the sub-merchant fixed on the payment or the
     * order. Payments made before the collecting sub-merchant was recorded were collected by the
     * configured platform merchant, so those fall back to it instead of the refund failing.
     */
    private String refundHuifuId(Object storedHuifuId) {
        String resolved = text(storedHuifuId);
        return resolved.isBlank() ? text(huifuPaymentProperties.getMerchantId()) : resolved;
    }
 
    private String requestDate(Object value) {
        if (value instanceof LocalDateTime dateTime) return REQUEST_DATE.format(dateTime);
        if (value instanceof LocalDate date) return date.format(DateTimeFormatter.BASIC_ISO_DATE);
        return REQUEST_DATE.format(LocalDateTime.now());
    }
 
    private List<Map<String, Object>> rows(String sql, Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql, args)); }
    private long number(Object value) { return value instanceof Number number ? number.longValue() : 0; }
    private String text(Object value) { return value == null ? "" : String.valueOf(value).trim(); }
 
    private record PreparedRefund(String refundNo, long orderId, String huifuId, String paymentNo, String originalProviderNo, String originalRequestDate, long refundAmount) {
        private PreparedRefund(String refundNo, long orderId, long refundAmount) { this(refundNo, orderId, "", "", "", "", refundAmount); }
    }
 
    private record ProviderResult(boolean success, boolean failed, String code, String message, String providerNo) {
        private static ProviderResult from(Map<String, Object> response) {
            Map<String, Object> data = response.get("data") instanceof Map<?, ?> map ? (Map<String, Object>) map : response;
            String status = textValue(data.get("trans_stat"));
            String code = textValue(data.get("sub_resp_code"));
            if (code.isBlank()) code = textValue(data.get("resp_code"));
            String message = textValue(data.get("sub_resp_desc"));
            if (message.isBlank()) message = textValue(data.get("resp_desc"));
            String providerNo = textValue(data.get("hf_seq_id"));
            boolean codeSuccess = "00000000".equals(code) || "10000".equals(code) || "0".equals(code) || "SUCCESS".equalsIgnoreCase(code);
            boolean success = codeSuccess && (status.isBlank() || "S".equalsIgnoreCase(status) || "SUCCESS".equalsIgnoreCase(status) || "PAID".equalsIgnoreCase(status));
            boolean failed = !success && !("P".equalsIgnoreCase(status) || "PROCESSING".equalsIgnoreCase(status) || "PENDING".equalsIgnoreCase(status));
            return new ProviderResult(success, failed, code, message, providerNo);
        }
        private static String textValue(Object value) { return value == null ? "" : String.valueOf(value).trim(); }
    }
}