feat(v2): 支付宝 adapter 落地 RefundingProvider(TradeRefund 同步退款,out_request_no=refund_id 幂等)

This commit is contained in:
wangjia
2026-07-10 17:29:59 +08:00
parent 2016c5d16e
commit cc6f8fb972
2 changed files with 170 additions and 1 deletions
+25 -1
View File
@@ -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)
@@ -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)
}
}