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