From 83337a0c6547037fbd122a6dcf0f6cc71679db08 Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 15 Oct 2026 22:03:15 +0800 Subject: [PATCH] fix(payment): query Alipay refund status and close unsupported order types - Alipay: record the generated out_request_no as the refund ID and implement QueryRefund (alipay.trade.fastpay.refund.query), so refunds that return fund_change=N can be finalized from the admin refund query. Refunds recorded before this change return REFUND_QUERY_UNSUPPORTED. - Fulfillment: mark paid orders with an unsupported order type as FAILED with an audit entry instead of erroring, so payment callbacks are acknowledged; RetryFulfillment rejects them with INVALID_ORDER_TYPE. --- backend/internal/payment/provider/alipay.go | 61 ++++++++++-- .../internal/payment/provider/alipay_test.go | 93 +++++++++++++++++++ .../internal/service/payment_fulfillment.go | 29 +++++- .../service/payment_order_type_test.go | 21 ++++- 4 files changed, 191 insertions(+), 13 deletions(-) diff --git a/backend/internal/payment/provider/alipay.go b/backend/internal/payment/provider/alipay.go index 42a9387171..df7ec54668 100644 --- a/backend/internal/payment/provider/alipay.go +++ b/backend/internal/payment/provider/alipay.go @@ -10,6 +10,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/payment" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/smartwalle/alipay/v3" ) @@ -24,7 +25,8 @@ const ( const ( alipayFundChangeYes = "Y" alipayErrTradeNotExist = "ACQ.TRADE_NOT_EXIST" - alipayRefundSuffix = "-refund" + alipayRefundStatusSucc = "REFUND_SUCCESS" + alipayRefundRequestSep = "-refund-" ) var ( @@ -37,6 +39,12 @@ var ( alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) { return client.TradePagePay(param) } + alipayTradeRefund = func(ctx context.Context, client *alipay.Client, param alipay.TradeRefund) (*alipay.TradeRefundRsp, error) { + return client.TradeRefund(ctx, param) + } + alipayTradeFastPayRefundQuery = func(ctx context.Context, client *alipay.Client, param alipay.TradeFastPayRefundQuery) (*alipay.TradeFastPayRefundQueryRsp, error) { + return client.TradeFastPayRefundQuery(ctx, param) + } ) // Alipay implements payment.Provider and payment.CancelableProvider using the smartwalle/alipay SDK. @@ -338,32 +346,66 @@ func (a *Alipay) Refund(ctx context.Context, req payment.RefundRequest) (*paymen return nil, err } - result, err := client.TradeRefund(ctx, alipay.TradeRefund{ + outRequestNo := fmt.Sprintf("%s%s%d", req.OrderID, alipayRefundRequestSep, time.Now().UnixNano()) + result, err := alipayTradeRefund(ctx, client, alipay.TradeRefund{ OutTradeNo: req.OrderID, RefundAmount: req.Amount, RefundReason: req.Reason, - OutRequestNo: fmt.Sprintf("%s-refund-%d", req.OrderID, time.Now().UnixNano()), + OutRequestNo: outRequestNo, }) if err != nil { return nil, fmt.Errorf("alipay TradeRefund: %w", err) } + if result == nil { + return nil, fmt.Errorf("alipay TradeRefund: empty response") + } refundStatus := payment.ProviderStatusPending if result.FundChange == alipayFundChangeYes { refundStatus = payment.ProviderStatusSuccess } - refundID := result.TradeNo - if refundID == "" { - refundID = req.OrderID + alipayRefundSuffix - } - + // fund_change != Y 时需凭退款请求号查询终态,因此 RefundID 记录 out_request_no。 return &payment.RefundResponse{ - RefundID: refundID, + RefundID: outRequestNo, Status: refundStatus, }, nil } +// 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) + // 旧版本把支付宝交易号记为 RefundID,无法据此查询退款请求,只能人工核实。 + if orderID == "" || !strings.HasPrefix(outRequestNo, orderID+alipayRefundRequestSep) { + return nil, infraerrors.BadRequest("REFUND_QUERY_UNSUPPORTED", "this refund was issued without a queryable refund request number; please verify manually") + } + client, err := a.getClient() + if err != nil { + return nil, err + } + result, err := alipayTradeFastPayRefundQuery(ctx, client, alipay.TradeFastPayRefundQuery{ + OutTradeNo: orderID, + OutRequestNo: outRequestNo, + }) + if err != nil { + return nil, fmt.Errorf("alipay query refund: %w", err) + } + if result == nil { + return nil, fmt.Errorf("alipay query refund: empty response") + } + + status := payment.ProviderStatusPending + switch { + case strings.TrimSpace(result.RefundStatus) == alipayRefundStatusSucc: + status = payment.ProviderStatusSuccess + case strings.TrimSpace(result.OutRequestNo) == "" && strings.TrimSpace(result.RefundAmount) == "": + status = payment.ProviderStatusFailed + } + return &payment.RefundResponse{RefundID: outRequestNo, Status: status}, nil +} + // CancelPayment closes a pending trade on Alipay. func (a *Alipay) CancelPayment(ctx context.Context, tradeNo string) error { client, err := a.getClient() @@ -406,5 +448,6 @@ func parseAlipayAmount(values ...string) (float64, error) { var ( _ payment.Provider = (*Alipay)(nil) _ payment.CancelableProvider = (*Alipay)(nil) + _ payment.RefundQueryProvider = (*Alipay)(nil) _ payment.MerchantIdentityProvider = (*Alipay)(nil) ) diff --git a/backend/internal/payment/provider/alipay_test.go b/backend/internal/payment/provider/alipay_test.go index bb725f8c2f..502af17a87 100644 --- a/backend/internal/payment/provider/alipay_test.go +++ b/backend/internal/payment/provider/alipay_test.go @@ -444,3 +444,96 @@ func TestParseAlipayAmount(t *testing.T) { t.Fatal("expected error when no valid amount field exists") } } + +func TestAlipayRefundRecordsOutRequestNoAsRefundID(t *testing.T) { + origRefund := alipayTradeRefund + t.Cleanup(func() { alipayTradeRefund = origRefund }) + + for _, tc := range []struct { + fundChange string + wantStatus string + }{ + {fundChange: alipayFundChangeYes, wantStatus: payment.ProviderStatusSuccess}, + {fundChange: "N", wantStatus: payment.ProviderStatusPending}, + } { + var gotOutRequestNo string + alipayTradeRefund = func(_ context.Context, _ *alipay.Client, param alipay.TradeRefund) (*alipay.TradeRefundRsp, error) { + if param.OutTradeNo != "sub2_7" || param.RefundAmount != "12.50" { + t.Fatalf("unexpected refund param: %+v", param) + } + gotOutRequestNo = param.OutRequestNo + return &alipay.TradeRefundRsp{TradeNo: "2026101522001", FundChange: tc.fundChange}, nil + } + + provider := &Alipay{client: &alipay.Client{}} + resp, err := provider.Refund(context.Background(), payment.RefundRequest{OrderID: "sub2_7", Amount: "12.50", Reason: "r"}) + if err != nil { + t.Fatalf("Refund() error = %v", err) + } + if resp.Status != tc.wantStatus { + t.Fatalf("fund_change=%s status = %q, want %q", tc.fundChange, resp.Status, tc.wantStatus) + } + if !strings.HasPrefix(gotOutRequestNo, "sub2_7-refund-") || resp.RefundID != gotOutRequestNo { + t.Fatalf("RefundID = %q, out_request_no = %q", resp.RefundID, gotOutRequestNo) + } + } +} + +func TestAlipayQueryRefundMapsStatus(t *testing.T) { + origQuery := alipayTradeFastPayRefundQuery + t.Cleanup(func() { alipayTradeFastPayRefundQuery = origQuery }) + + const outRequestNo = "sub2_7-refund-1760500000000000000" + for _, tc := range []struct { + name string + rsp *alipay.TradeFastPayRefundQueryRsp + 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}, + } { + t.Run(tc.name, func(t *testing.T) { + alipayTradeFastPayRefundQuery = func(_ context.Context, _ *alipay.Client, param alipay.TradeFastPayRefundQuery) (*alipay.TradeFastPayRefundQueryRsp, error) { + if param.OutTradeNo != "sub2_7" || param.OutRequestNo != outRequestNo { + t.Fatalf("unexpected query param: %+v", param) + } + return tc.rsp, nil + } + provider := &Alipay{client: &alipay.Client{}} + resp, err := provider.QueryRefund(context.Background(), payment.RefundQueryRequest{OrderID: "sub2_7", RefundID: outRequestNo}) + if err != nil { + t.Fatalf("QueryRefund() error = %v", err) + } + if resp.Status != tc.wantStatus || resp.RefundID != outRequestNo { + t.Fatalf("QueryRefund() = %+v, want status %q", resp, tc.wantStatus) + } + }) + } +} + +func TestAlipayQueryRefundErrorsKeepRefundPending(t *testing.T) { + origQuery := alipayTradeFastPayRefundQuery + t.Cleanup(func() { alipayTradeFastPayRefundQuery = origQuery }) + + calls := 0 + alipayTradeFastPayRefundQuery = func(context.Context, *alipay.Client, alipay.TradeFastPayRefundQuery) (*alipay.TradeFastPayRefundQueryRsp, error) { + calls++ + return nil, errors.New("ACQ.SYSTEM_ERROR") + } + provider := &Alipay{client: &alipay.Client{}} + + // 旧版本记录的是支付宝交易号,无法查询退款请求,需人工核实,且不能调用网关。 + for _, legacyID := range []string{"", "2026101522001", "sub2_7-refund", "sub2_70-refund-1"} { + if _, err := provider.QueryRefund(context.Background(), payment.RefundQueryRequest{OrderID: "sub2_7", RefundID: legacyID}); err == nil || !strings.Contains(err.Error(), "verify manually") { + t.Fatalf("legacy refund id %q: err = %v", legacyID, err) + } + } + if calls != 0 { + t.Fatalf("legacy refund ids must not reach the gateway, calls = %d", calls) + } + + if _, err := provider.QueryRefund(context.Background(), payment.RefundQueryRequest{OrderID: "sub2_7", RefundID: "sub2_7-refund-1"}); err == nil { + t.Fatal("gateway error must be returned so the refund stays pending") + } +} diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go index f5b2ad05f1..e405ce9840 100644 --- a/backend/internal/service/payment_fulfillment.go +++ b/backend/internal/service/payment_fulfillment.go @@ -224,7 +224,31 @@ func (s *PaymentService) executeFulfillment(ctx context.Context, oid int64) erro case payment.OrderTypeBalance: return s.ExecuteBalanceFulfillment(ctx, oid) default: - return infraerrors.BadRequest("INVALID_ORDER_TYPE", "unsupported order type "+o.OrderType) + // 不支持的订单类型不履约:标记 FAILED 后正常返回,避免支付回调持续 500 重试。 + s.rejectUnsupportedOrderType(ctx, o) + return nil + } +} + +func isSupportedPaymentOrderType(orderType string) bool { + return orderType == payment.OrderTypeBalance || orderType == payment.OrderTypeSubscription +} + +func (s *PaymentService) rejectUnsupportedOrderType(ctx context.Context, o *dbent.PaymentOrder) { + reason := "unsupported order type: " + o.OrderType + c, err := s.entClient.PaymentOrder.Update(). + Where( + paymentorder.IDEQ(o.ID), + paymentorder.StatusIn(OrderStatusPaid, OrderStatusRecharging), + ). + SetStatus(OrderStatusFailed).SetFailedAt(time.Now()).SetFailedReason(reason).Save(ctx) + if err != nil { + slog.Error("mark unsupported order type FAILED", "orderID", o.ID, "error", err) + return + } + if c > 0 { + slog.Warn("payment order with unsupported order type not fulfilled", "orderID", o.ID, "orderType", o.OrderType) + s.writeAuditLog(ctx, o.ID, "FULFILLMENT_FAILED", "system", map[string]any{"reason": reason}) } } @@ -892,6 +916,9 @@ func (s *PaymentService) RetryFulfillment(ctx context.Context, oid int64) error if o.Status != OrderStatusFailed && o.Status != OrderStatusPaid && o.Status != OrderStatusRecharging { return infraerrors.BadRequest("INVALID_STATUS", "only paid, failed, and recoverable recharging orders can retry") } + if !isSupportedPaymentOrderType(o.OrderType) { + return infraerrors.BadRequest("INVALID_ORDER_TYPE", "unsupported order type") + } s.writeAuditLog(ctx, oid, "RECHARGE_RETRY", "admin", map[string]any{"detail": "admin manual retry"}) return s.executeFulfillment(ctx, oid) } diff --git a/backend/internal/service/payment_order_type_test.go b/backend/internal/service/payment_order_type_test.go index 114e67a91f..e344db5de4 100644 --- a/backend/internal/service/payment_order_type_test.go +++ b/backend/internal/service/payment_order_type_test.go @@ -4,9 +4,11 @@ package service import ( "context" + "strconv" "testing" "time" + "github.com/Wei-Shaw/sub2api/ent/paymentauditlog" "github.com/Wei-Shaw/sub2api/internal/payment" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/stretchr/testify/require" @@ -54,12 +56,25 @@ func TestFulfillmentRejectsUnknownOrderType(t *testing.T) { } svc := &PaymentService{entClient: client, userRepo: userRepo} - err = svc.executeFulfillment(ctx, order.ID) - require.Equal(t, "INVALID_ORDER_TYPE", infraerrors.Reason(err)) err = svc.ExecuteBalanceFulfillment(ctx, order.ID) require.Equal(t, "INVALID_ORDER_TYPE", infraerrors.Reason(err)) + // 不履约,但标记 FAILED 并正常返回,使支付回调得到确认而不是无限重试。 + require.NoError(t, svc.executeFulfillment(ctx, order.ID)) reloaded, err := client.PaymentOrder.Get(ctx, order.ID) require.NoError(t, err) - require.Equal(t, OrderStatusPaid, reloaded.Status) + require.Equal(t, OrderStatusFailed, reloaded.Status) + require.NotNil(t, reloaded.FailedReason) + require.Contains(t, *reloaded.FailedReason, "unsupported order type") + + // 回调重试经 alreadyProcessed 再次进入履约:幂等,不重复写审计。 + require.NoError(t, svc.executeFulfillment(ctx, order.ID)) + audits, err := client.PaymentAuditLog.Query(). + Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("FULFILLMENT_FAILED")). + Count(ctx) + require.NoError(t, err) + require.Equal(t, 1, audits) + + err = svc.RetryFulfillment(ctx, order.ID) + require.Equal(t, "INVALID_ORDER_TYPE", infraerrors.Reason(err)) }