diff --git a/backend/internal/payment/provider/alipay.go b/backend/internal/payment/provider/alipay.go index df7ec54668..dbd0e9cccb 100644 --- a/backend/internal/payment/provider/alipay.go +++ b/backend/internal/payment/provider/alipay.go @@ -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() diff --git a/backend/internal/payment/provider/alipay_test.go b/backend/internal/payment/provider/alipay_test.go index 502af17a87..a2264b2eef 100644 --- a/backend/internal/payment/provider/alipay_test.go +++ b/backend/internal/payment/provider/alipay_test.go @@ -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 }) diff --git a/backend/internal/payment/types.go b/backend/internal/payment/types.go index 6421c4afed..8806f669b1 100644 --- a/backend/internal/payment/types.go +++ b/backend/internal/payment/types.go @@ -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. diff --git a/backend/internal/service/payment_refund.go b/backend/internal/service/payment_refund.go index f5d5bedd7a..d4e313be8a 100644 --- a/backend/internal/service/payment_refund.go +++ b/backend/internal/service/payment_refund.go @@ -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 } diff --git a/backend/internal/service/payment_refund_test.go b/backend/internal/service/payment_refund_test.go index 51a7603384..834c7f4571 100644 --- a/backend/internal/service/payment_refund_test.go +++ b/backend/internal/service/payment_refund_test.go @@ -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 }