mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 23:22:59 +08:00
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:
@@ -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 })
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user