feat(v2): 支付宝 adapter 落地 RefundingProvider(TradeRefund 同步退款,out_request_no=refund_id 幂等)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user