fix(payment): treat Alipay refund query errors as pending and delay not-found failure

- The SDK returns signed business-failure responses (for example
  ACQ.SYSTEM_ERROR) with a nil error and empty fields. QueryRefund now
  checks the response code and returns an error, so the order stays
  REFUND_PENDING instead of being read as "no refund record" and failed.
- A missing refund record is treated as failed only after a 10 minute
  grace period from when the refund became pending. Alipay notes that a
  query sent too soon can miss a refund that later succeeds. The pending
  time comes from the REFUND_PENDING audit entry.
This commit is contained in:
shaw
2026-10-15 22:26:53 +08:00
parent 83337a0c65
commit f37b69b779
5 changed files with 92 additions and 15 deletions
+14 -2
View File
@@ -27,6 +27,8 @@ const (
alipayErrTradeNotExist = "ACQ.TRADE_NOT_EXIST"
alipayRefundStatusSucc = "REFUND_SUCCESS"
alipayRefundRequestSep = "-refund-"
// 支付宝提示退款发起后过早查询可能查不到、之后又处理成功;超过该时长仍无记录才判定失败。
alipayRefundQueryNotFoundGrace = 10 * time.Minute
)
var (
@@ -373,7 +375,7 @@ func (a *Alipay) Refund(ctx context.Context, req payment.RefundRequest) (*paymen
}
// QueryRefund 通过 alipay.trade.fastpay.refund.query 查询退款终态。refund_status 为 REFUND_SUCCESS
// 表示成功;查不到退款记录表示支付宝未受理或退款失败;有记录但未成功视为仍在处理中。
// 表示成功;退款发起超过宽限期仍查不到记录表示支付宝未受理或退款失败;其余情况视为仍在处理中。
func (a *Alipay) QueryRefund(ctx context.Context, req payment.RefundQueryRequest) (*payment.RefundResponse, error) {
orderID := strings.TrimSpace(req.OrderID)
outRequestNo := strings.TrimSpace(req.RefundID)
@@ -395,17 +397,27 @@ func (a *Alipay) QueryRefund(ctx context.Context, req payment.RefundQueryRequest
if result == nil {
return nil, fmt.Errorf("alipay query refund: empty response")
}
// SDK 对带签名的业务失败响应(如 ACQ.SYSTEM_ERROR)不返回 error,字段为空;
// 不检查错误码会被误判为"无退款记录"而失败收尾。
if result.IsFailure() {
return nil, fmt.Errorf("alipay query refund: %w", result.Error)
}
status := payment.ProviderStatusPending
switch {
case strings.TrimSpace(result.RefundStatus) == alipayRefundStatusSucc:
status = payment.ProviderStatusSuccess
case strings.TrimSpace(result.OutRequestNo) == "" && strings.TrimSpace(result.RefundAmount) == "":
case strings.TrimSpace(result.OutRequestNo) == "" && strings.TrimSpace(result.RefundAmount) == "" &&
alipayRefundAbsenceIsFinal(req.RequestedAt, time.Now()):
status = payment.ProviderStatusFailed
}
return &payment.RefundResponse{RefundID: outRequestNo, Status: status}, nil
}
func alipayRefundAbsenceIsFinal(requestedAt, now time.Time) bool {
return !requestedAt.IsZero() && now.Sub(requestedAt) >= alipayRefundQueryNotFoundGrace
}
// CancelPayment closes a pending trade on Alipay.
func (a *Alipay) CancelPayment(ctx context.Context, tradeNo string) error {
client, err := a.getClient()
@@ -8,6 +8,7 @@ import (
"net/url"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/smartwalle/alipay/v3"
@@ -484,14 +485,19 @@ func TestAlipayQueryRefundMapsStatus(t *testing.T) {
t.Cleanup(func() { alipayTradeFastPayRefundQuery = origQuery })
const outRequestNo = "sub2_7-refund-1760500000000000000"
ok := alipay.Error{Code: alipay.CodeSuccess, Msg: "Success"}
settled := time.Now().Add(-alipayRefundQueryNotFoundGrace - time.Minute)
for _, tc := range []struct {
name string
rsp *alipay.TradeFastPayRefundQueryRsp
wantStatus string
name string
rsp *alipay.TradeFastPayRefundQueryRsp
requestedAt time.Time
wantStatus string
}{
{name: "success", rsp: &alipay.TradeFastPayRefundQueryRsp{OutRequestNo: outRequestNo, RefundAmount: "12.50", RefundStatus: alipayRefundStatusSucc}, wantStatus: payment.ProviderStatusSuccess},
{name: "record without final status", rsp: &alipay.TradeFastPayRefundQueryRsp{OutRequestNo: outRequestNo, RefundAmount: "12.50"}, wantStatus: payment.ProviderStatusPending},
{name: "no refund record", rsp: &alipay.TradeFastPayRefundQueryRsp{}, wantStatus: payment.ProviderStatusFailed},
{name: "success", rsp: &alipay.TradeFastPayRefundQueryRsp{Error: ok, OutRequestNo: outRequestNo, RefundAmount: "12.50", RefundStatus: alipayRefundStatusSucc}, requestedAt: settled, wantStatus: payment.ProviderStatusSuccess},
{name: "record without final status", rsp: &alipay.TradeFastPayRefundQueryRsp{Error: ok, OutRequestNo: outRequestNo, RefundAmount: "12.50"}, requestedAt: settled, wantStatus: payment.ProviderStatusPending},
{name: "no refund record after grace", rsp: &alipay.TradeFastPayRefundQueryRsp{Error: ok}, requestedAt: settled, wantStatus: payment.ProviderStatusFailed},
{name: "no refund record within grace", rsp: &alipay.TradeFastPayRefundQueryRsp{Error: ok}, requestedAt: time.Now().Add(-time.Minute), wantStatus: payment.ProviderStatusPending},
{name: "no refund record with unknown request time", rsp: &alipay.TradeFastPayRefundQueryRsp{Error: ok}, wantStatus: payment.ProviderStatusPending},
} {
t.Run(tc.name, func(t *testing.T) {
alipayTradeFastPayRefundQuery = func(_ context.Context, _ *alipay.Client, param alipay.TradeFastPayRefundQuery) (*alipay.TradeFastPayRefundQueryRsp, error) {
@@ -501,7 +507,7 @@ func TestAlipayQueryRefundMapsStatus(t *testing.T) {
return tc.rsp, nil
}
provider := &Alipay{client: &alipay.Client{}}
resp, err := provider.QueryRefund(context.Background(), payment.RefundQueryRequest{OrderID: "sub2_7", RefundID: outRequestNo})
resp, err := provider.QueryRefund(context.Background(), payment.RefundQueryRequest{OrderID: "sub2_7", RefundID: outRequestNo, RequestedAt: tc.requestedAt})
if err != nil {
t.Fatalf("QueryRefund() error = %v", err)
}
@@ -512,6 +518,34 @@ func TestAlipayQueryRefundMapsStatus(t *testing.T) {
}
}
// SDK 对带签名的业务失败响应返回 err == nil,错误码只在响应体里。
func TestAlipayQueryRefundBusinessFailureKeepsRefundPending(t *testing.T) {
origQuery := alipayTradeFastPayRefundQuery
t.Cleanup(func() { alipayTradeFastPayRefundQuery = origQuery })
for _, rspErr := range []alipay.Error{
{Code: "40004", Msg: "Business Failed", SubCode: "ACQ.SYSTEM_ERROR", SubMsg: "系统错误"},
{Code: "20000", Msg: "Service Currently Unavailable", SubCode: "isp.unknow-error"},
{},
} {
alipayTradeFastPayRefundQuery = func(context.Context, *alipay.Client, alipay.TradeFastPayRefundQuery) (*alipay.TradeFastPayRefundQueryRsp, error) {
return &alipay.TradeFastPayRefundQueryRsp{Error: rspErr}, nil
}
provider := &Alipay{client: &alipay.Client{}}
resp, err := provider.QueryRefund(context.Background(), payment.RefundQueryRequest{
OrderID: "sub2_7",
RefundID: "sub2_7-refund-1",
RequestedAt: time.Now().Add(-24 * time.Hour),
})
if err == nil {
t.Fatalf("code %q: QueryRefund() = %+v, want error", rspErr.Code, resp)
}
if rspErr.SubCode != "" && !strings.Contains(err.Error(), string(rspErr.Code)) {
t.Fatalf("code %q: error %q should carry the gateway code", rspErr.Code, err)
}
}
}
func TestAlipayQueryRefundErrorsKeepRefundPending(t *testing.T) {
origQuery := alipayTradeFastPayRefundQuery
t.Cleanup(func() { alipayTradeFastPayRefundQuery = origQuery })
+6 -1
View File
@@ -2,7 +2,10 @@
// registry, load balancing, and shared utilities for the payment subsystem.
package payment
import "context"
import (
"context"
"time"
)
// PaymentType represents a supported payment method.
type PaymentType = string
@@ -192,6 +195,8 @@ type RefundQueryRequest struct {
OrderID string
RefundID string
Amount string
// RequestedAt 为退款进入 pending 的时间;零值表示未知。
RequestedAt time.Time
}
// RefundResponse is returned after a refund request.
+8 -4
View File
@@ -452,10 +452,11 @@ func (s *PaymentService) QueryAndFinalizeRefund(ctx context.Context, oid int64)
}
finishProviderCall := servertiming.ObserveDependency(ctx, "payment")
resp, err := queryProvider.QueryRefund(ctx, payment.RefundQueryRequest{
TradeNo: o.PaymentTradeNo,
OrderID: o.OutTradeNo,
RefundID: pendingDetail.RefundID,
Amount: formatGatewayRefundAmount(o.RefundAmount, o),
TradeNo: o.PaymentTradeNo,
OrderID: o.OutTradeNo,
RefundID: pendingDetail.RefundID,
Amount: formatGatewayRefundAmount(o.RefundAmount, o),
RequestedAt: pendingDetail.requestedAt,
})
finishProviderCall()
if err != nil {
@@ -699,6 +700,8 @@ type refundPendingAuditDetail struct {
BalanceDeducted float64 `json:"balanceDeducted"`
SubDaysDeducted int `json:"subDaysDeducted"`
SubscriptionID int64 `json:"subscriptionID,omitempty"`
// requestedAt 取自审计记录的创建时间,不参与序列化。
requestedAt time.Time
}
func (d refundPendingAuditDetail) applyTo(p *RefundPlan) {
@@ -730,6 +733,7 @@ func (s *PaymentService) latestRefundPendingDetail(ctx context.Context, oid int6
return refundPendingAuditDetail{}, fmt.Errorf("parse refund pending audit: %w", err)
}
detail.RefundID = strings.TrimSpace(detail.RefundID)
detail.requestedAt = logEntry.CreatedAt
return detail, nil
}
@@ -507,6 +507,26 @@ func TestQueryAndFinalizeRefundFinalizesProviderStatuses(t *testing.T) {
}
}
func TestQueryAndFinalizeRefundPassesPendingAuditTimeToProvider(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
order := createPendingRefundOrderForTest(t, ctx, client, "query-requested-at")
pendingAudit, err := client.PaymentAuditLog.Query().
Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_PENDING")).
Only(ctx)
require.NoError(t, err)
svc := &PaymentService{entClient: client, loadBalancer: &captureLoadBalancer{}, userRepo: &mockUserRepo{}}
prov := &refundQueryProviderTestDouble{refundResponse: &payment.RefundResponse{RefundID: "rf_test", Status: payment.ProviderStatusPending}}
restore := replacePaymentProviderFactoryForTest(t, prov)
defer restore()
_, err = svc.QueryAndFinalizeRefund(ctx, order.ID)
require.NoError(t, err)
require.Equal(t, "rf_test", prov.lastQuery.RefundID)
require.True(t, pendingAudit.CreatedAt.Equal(prov.lastQuery.RequestedAt), "RequestedAt = %v, want %v", prov.lastQuery.RequestedAt, pendingAudit.CreatedAt)
}
func TestFinalizePendingRefundSuccessRejectsStaleCallerBeforeSecondDeduction(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
@@ -681,8 +701,10 @@ func (refundProviderTestDouble) Refund(context.Context, payment.RefundRequest) (
type refundQueryProviderTestDouble struct {
refundProviderTestDouble
refundResponse *payment.RefundResponse
lastQuery payment.RefundQueryRequest
}
func (p *refundQueryProviderTestDouble) QueryRefund(context.Context, payment.RefundQueryRequest) (*payment.RefundResponse, error) {
func (p *refundQueryProviderTestDouble) QueryRefund(_ context.Context, req payment.RefundQueryRequest) (*payment.RefundResponse, error) {
p.lastQuery = req
return p.refundResponse, nil
}