e26f995d7f
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
180 lines
6.8 KiB
Go
180 lines
6.8 KiB
Go
package alipay_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
sw "github.com/smartwalle/alipay/v3"
|
|
|
|
"github.com/wangjia/pay/internal/money"
|
|
"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。
|
|
// TestAlipayRefundChannelRejected 的响应必须像 fakeAlipayRefund 一样用"支付宝侧"私钥
|
|
// 签名——否则 DecodeNotification/TradeRefund 在验签阶段就报错,走的是传输错误分支
|
|
// (rsp 根本拿不到),永远到不了下面要验证的 rsp.IsFailure() 分支,原始 fixture 未签名
|
|
// 这个测试其实是假绿的(见 P4 item 2)。
|
|
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)
|
|
h := sha256.Sum256(nodeJSON)
|
|
sig, _ := rsa.SignPKCS1v15(rand.Reader, aliKey, 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)
|
|
}))
|
|
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")
|
|
}
|
|
// 真正走到 rsp.IsFailure() 分支后,error 须满足 errors.Is(..., ErrRefundRejected)——
|
|
// 与 item 1 配套:gateway.Refund 靠这个哨兵区分"渠道确定性拒绝"(可标 failed)
|
|
// vs"结果不确定"(须留 processing)。
|
|
if !errors.Is(err, provider.ErrRefundRejected) {
|
|
t.Fatalf("err = %v want wrap ErrRefundRejected", err)
|
|
}
|
|
if status != provider.PaidFailed || ref != "" {
|
|
t.Fatalf("refund result: ref=%q status=%s", ref, status)
|
|
}
|
|
}
|
|
|
|
// TestMoneyFormatStripsTrailingZerosForAlipay 记录一个既定行为(防静默回归):
|
|
// money.Format 对整元金额去尾零,19900 分(199.00 元)格式化成 "199" 而非 "199.00"。
|
|
// 上面所有 alipay refund 用例传的 19900 分走的正是这条路径(见 p.Refund 内
|
|
// money.Format(amountMinor,"CNY") 再作为 RefundAmount 传给 alipay.trade.refund)。
|
|
// 支付宝接受这种去尾零形态的金额字符串,且与建单路径(Create → money.Format)同源、
|
|
// 已被 TestParseFormatRoundTrip 覆盖一致性,这里单独钉一行防止 alipay refund 路径
|
|
// 依赖的具体字符串形态被静默改掉。
|
|
func TestMoneyFormatStripsTrailingZerosForAlipay(t *testing.T) {
|
|
got, err := money.Format(19900, "CNY")
|
|
if err != nil {
|
|
t.Fatalf("money.Format(19900,CNY): %v", err)
|
|
}
|
|
if got != "199" {
|
|
t.Fatalf("money.Format(19900,CNY) = %q want %q(既定去尾零行为)", got, "199")
|
|
}
|
|
}
|