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) } }