From cc6f8fb9722485755e654d9073fd1aeeafa3ff20 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 17:29:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(v2):=20=E6=94=AF=E4=BB=98=E5=AE=9D=20adapt?= =?UTF-8?q?er=20=E8=90=BD=E5=9C=B0=20RefundingProvider(TradeRefund=20?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E9=80=80=E6=AC=BE,out=5Frequest=5Fno=3Drefun?= =?UTF-8?q?d=5Fid=20=E5=B9=82=E7=AD=89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/provider/alipay/alipay.go | 26 +++- .../provider/alipay/alipay_refund_test.go | 145 ++++++++++++++++++ 2 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 internal/provider/alipay/alipay_refund_test.go diff --git a/internal/provider/alipay/alipay.go b/internal/provider/alipay/alipay.go index ecf6dd2..aee6827 100644 --- a/internal/provider/alipay/alipay.go +++ b/internal/provider/alipay/alipay.go @@ -31,12 +31,36 @@ func (p *Provider) Method() string { return "alipay" } func (p *Provider) Capabilities() provider.Capabilities { return provider.Capabilities{ RenderTypes: []provider.RenderType{provider.RenderRedirect}, - SupportsRefund: false, // 退款 P4 + SupportsRefund: true, // P4:alipay.trade.refund 同步退款 SettleCurrencies: []string{"CNY"}, Regions: []string{"cn"}, } } +// Refund 调 alipay.trade.refund(同步接口)。providerRef=out_trade_no(alipay 归位键), +// refundID 作 out_request_no(部分退款必传且须唯一稳定 → 幂等)。IsFailure()==false 即成功 +// (FundChange=N 表重复退款已幂等,仍算成功)。 +func (p *Provider) Refund(ctx context.Context, providerRef, refundID string, amountMinor int64, reason string) (string, provider.PaidStatus, error) { + amt, err := money.Format(amountMinor, "CNY") + if err != nil { + return "", provider.PaidFailed, err + } + rsp, err := p.client.TradeRefund(ctx, sw.TradeRefund{ + OutTradeNo: providerRef, + RefundAmount: amt, + RefundReason: reason, + OutRequestNo: refundID, + }) + if err != nil { + return "", provider.PaidFailed, fmt.Errorf("alipay: 退款请求失败: %w", err) + } + if rsp.IsFailure() { + return "", provider.PaidFailed, fmt.Errorf("alipay: 退款被拒: %w", rsp.Error) + } + // 退款在支付宝侧以 out_request_no 定位;refundRef 回传我方退款单号(= out_request_no)。 + return refundID, provider.PaidSucceeded, nil +} + func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (*provider.Session, error) { if req.Currency != "CNY" { return nil, fmt.Errorf("alipay: 仅支持 CNY, got %s", req.Currency) diff --git a/internal/provider/alipay/alipay_refund_test.go b/internal/provider/alipay/alipay_refund_test.go new file mode 100644 index 0000000..401a842 --- /dev/null +++ b/internal/provider/alipay/alipay_refund_test.go @@ -0,0 +1,145 @@ +package alipay_test + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + sw "github.com/smartwalle/alipay/v3" + + "github.com/wangjia/pay/internal/provider" + ali "github.com/wangjia/pay/internal/provider/alipay" +) + +// fakeAlipayRefund 返回一份用"支付宝侧"私钥签名的 alipay.trade.refund 响应。 +func fakeAlipayRefund(t *testing.T, aliPriv *rsa.PrivateKey, fundChange string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + node := map[string]any{ + "code": "10000", "msg": "Success", + "trade_no": "2021AAA", "out_trade_no": "PAY-1", + "refund_fee": "199.00", "fund_change": fundChange, + } + nodeJSON, _ := json.Marshal(node) + // 支付宝对 response node 的原文做 RSA2 签名(sign_type=RSA2)。 + h := sha256.Sum256(nodeJSON) + sig, _ := rsa.SignPKCS1v15(rand.Reader, aliPriv, crypto.SHA256, h[:]) + resp := map[string]any{ + "alipay_trade_refund_response": json.RawMessage(nodeJSON), + "sign": base64.StdEncoding.EncodeToString(sig), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) +} + +// buildRefundClient 与 alipay_test.go 的 genKeys/buildClient 同一套约定: +// +// ⚠️ 与 brief 草稿的差异(执行时发现,已按 SDK 实际要求修正,和 alipay_test.go 注释一致): +// 1. LoadAliPayPublicKey 内部走 PKIX(SubjectPublicKeyInfo)解码,不是 PKCS1 —— +// 用 x509.MarshalPKIXPublicKey 而非 MarshalPKCS1PublicKey。 +// 2. smartwalle v3.2.29 没有 sw.WithGateway 这个 option;设置网关的是 +// WithSandboxGateway(gateway)/WithProductionGateway(gateway)。client 用 +// production=false 建(沙箱),对应用 WithSandboxGateway 把 host 指到 httptest。 +func buildRefundClient(t *testing.T, gateway string, aliPub *rsa.PublicKey) *sw.Client { + t.Helper() + app, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("gen app key: %v", err) + } + c, err := sw.New( + "2021000000000000", + base64.StdEncoding.EncodeToString(x509.MarshalPKCS1PrivateKey(app)), + false, + sw.WithSandboxGateway(gateway), + ) + if err != nil { + t.Fatalf("new client: %v", err) + } + aliPubDER, err := x509.MarshalPKIXPublicKey(aliPub) + if err != nil { + t.Fatalf("marshal ali pub: %v", err) + } + if err := c.LoadAliPayPublicKey(base64.StdEncoding.EncodeToString(aliPubDER)); err != nil { + t.Fatalf("load pub: %v", err) + } + return c +} + +func TestAlipayRefundSyncSuccess(t *testing.T) { + aliKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("gen ali key: %v", err) + } + ts := fakeAlipayRefund(t, aliKey, "Y") + defer ts.Close() + p := ali.New(buildRefundClient(t, ts.URL, &aliKey.PublicKey)) + + if !p.Capabilities().SupportsRefund { + t.Fatal("alipay Capabilities.SupportsRefund 应为 true") + } + ref, status, err := p.Refund(context.Background(), "PAY-1", "rf-123", 19900, "用户申请") + if err != nil { + t.Fatalf("refund: %v", err) + } + if status != provider.PaidSucceeded || ref != "rf-123" { + t.Fatalf("refund result: ref=%s status=%s", ref, status) + } +} + +// FundChange=N 表示重复退款(同 out_request_no 幂等命中,支付宝侧未再发生资金变化)—— +// 仍是"受理成功",不能当失败处理,否则重试路径会把已完成的退款误判为失败。 +func TestAlipayRefundIdempotentNoFundChange(t *testing.T) { + aliKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("gen ali key: %v", err) + } + ts := fakeAlipayRefund(t, aliKey, "N") + defer ts.Close() + p := ali.New(buildRefundClient(t, ts.URL, &aliKey.PublicKey)) + + ref, status, err := p.Refund(context.Background(), "PAY-1", "rf-123", 19900, "用户申请") + if err != nil { + t.Fatalf("refund: %v", err) + } + if status != provider.PaidSucceeded || ref != "rf-123" { + t.Fatalf("refund result: ref=%s status=%s", ref, status) + } +} + +// 渠道拒绝(如金额超出可退余额)返回 code!=10000 的失败响应 —— TradeRefund 应把 +// rsp.Error 包出去,refundRef 留空,状态归 PaidFailed。 +func TestAlipayRefundChannelRejected(t *testing.T) { + aliKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("gen ali key: %v", err) + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + node := map[string]any{ + "code": "40004", "msg": "Business Failed", + "sub_code": "ACQ.TRADE_HAS_FINISHED", "sub_msg": "交易已完结", + } + nodeJSON, _ := json.Marshal(node) + resp := map[string]any{"alipay_trade_refund_response": json.RawMessage(nodeJSON)} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + p := ali.New(buildRefundClient(t, ts.URL, &aliKey.PublicKey)) + + ref, status, err := p.Refund(context.Background(), "PAY-1", "rf-123", 19900, "用户申请") + if err == nil { + t.Fatal("want 渠道拒绝返回 error") + } + if status != provider.PaidFailed || ref != "" { + t.Fatalf("refund result: ref=%q status=%s", ref, status) + } +}