feat(v2): alipay adapter — 迁移 v1 验签/查单,render_type=redirect(page/wap)
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
// Package alipay adapts Alipay web payment (page/wap) to provider.Provider,
|
||||
// porting v1 internal/channel/alipay.go's verify/query logic. The *alipay.Client
|
||||
// (with app private key + Alipay public key) is built from env credentials at
|
||||
// assembly time and injected, so verify_callback/query hold their own credentials.
|
||||
package alipay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
sw "github.com/smartwalle/alipay/v3"
|
||||
|
||||
"github.com/wangjia/pay/internal/money"
|
||||
"github.com/wangjia/pay/internal/provider"
|
||||
)
|
||||
|
||||
// gmtPaymentLayout 支付宝 gmt_payment/gmt_create 等时间字段格式,固定北京时间(无时区位),
|
||||
// 用 FixedZone 而非 LoadLocation("Asia/Shanghai") 避免依赖运行环境是否内置 tzdata。
|
||||
const gmtPaymentLayout = "2006-01-02 15:04:05"
|
||||
|
||||
var cst = time.FixedZone("CST", 8*3600)
|
||||
|
||||
type Provider struct{ client *sw.Client }
|
||||
|
||||
func New(client *sw.Client) *Provider { return &Provider{client: client} }
|
||||
|
||||
func (p *Provider) Method() string { return "alipay" }
|
||||
|
||||
func (p *Provider) Capabilities() provider.Capabilities {
|
||||
return provider.Capabilities{
|
||||
RenderTypes: []provider.RenderType{provider.RenderRedirect},
|
||||
SupportsRefund: false, // 退款 P4
|
||||
SettleCurrencies: []string{"CNY"},
|
||||
Regions: []string{"cn"},
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
amount, err := money.Format(req.AmountMinor, "CNY")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var payURL *url.URL
|
||||
if req.Metadata["is_mobile"] == "1" {
|
||||
wp := sw.TradeWapPay{}
|
||||
wp.OutTradeNo = req.OutTradeNo
|
||||
wp.Subject = req.Subject
|
||||
wp.TotalAmount = amount
|
||||
wp.ProductCode = "QUICK_WAP_WAY"
|
||||
wp.ReturnURL = req.ReturnURL
|
||||
payURL, err = p.client.TradeWapPay(wp)
|
||||
} else {
|
||||
pp := sw.TradePagePay{}
|
||||
pp.OutTradeNo = req.OutTradeNo
|
||||
pp.Subject = req.Subject
|
||||
pp.TotalAmount = amount
|
||||
pp.ProductCode = "FAST_INSTANT_TRADE_PAY"
|
||||
pp.ReturnURL = req.ReturnURL
|
||||
pp.QRPayMode = "2" // 跳转到完整扫码收银台(迁移 v1 语义)
|
||||
payURL, err = p.client.TradePagePay(pp)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay: 下单失败: %w", err)
|
||||
}
|
||||
return &provider.Session{
|
||||
ProviderRef: req.OutTradeNo, // 支付宝以 out_trade_no 归位
|
||||
RenderType: provider.RenderRedirect,
|
||||
Payload: map[string]any{"url": payURL.String()},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) VerifyCallback(ctx context.Context, in provider.CallbackInput) (*provider.PaidEvent, error) {
|
||||
form, err := url.ParseQuery(string(in.Raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay: 解析回调表单失败: %w", err)
|
||||
}
|
||||
// smartwalle/alipay v3.2.29 的 DecodeNotification 签名是 (ctx, values),
|
||||
// 内部用已加载支付宝公钥验签(见 v1 internal/channel/alipay.go 同用法)。
|
||||
noti, err := p.client.DecodeNotification(ctx, form)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay: 回调验签失败: %w", err)
|
||||
}
|
||||
paidAt := parseGmtPayment(noti.GmtPayment)
|
||||
return notifyToEvent(noti.OutTradeNo, string(noti.TradeStatus), noti.TotalAmount, paidAt, in.Raw)
|
||||
}
|
||||
|
||||
func (p *Provider) Query(ctx context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) {
|
||||
rsp, err := p.client.TradeQuery(ctx, sw.TradeQuery{OutTradeNo: req.OutTradeNo})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay: 查单失败: %w", err)
|
||||
}
|
||||
if rsp.IsFailure() {
|
||||
// 交易不存在等:视为未命中(pending),交管线继续轮询。
|
||||
return &provider.PaidEvent{ProviderRef: req.ProviderRef, Status: provider.PaidPending}, nil
|
||||
}
|
||||
// TradeQueryRsp 不带 gmt_payment(只有异步通知才有);查单场景 PaidAt 留 nil,
|
||||
// 由 settle 按 D4-A3 兜底用收到时间——不用 SendPayDate("打款给卖家时间"),
|
||||
// 那是结算时点,不是买家付款时点,拿来充 PaidAt 会误导对账。
|
||||
ev, err := notifyToEvent(rsp.OutTradeNo, string(rsp.TradeStatus), rsp.TotalAmount, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ev.ProviderRef = req.ProviderRef
|
||||
return ev, nil
|
||||
}
|
||||
|
||||
// parseGmtPayment 解析支付宝 "2006-01-02 15:04:05" 格式的付款时间(固定北京时间,无时区位)。
|
||||
// 为空或解析失败均返回 nil——交 settle 按 D4-A3 兜底用收到时间,不因此让 VerifyCallback 报错。
|
||||
func parseGmtPayment(s string) *time.Time {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
t, err := time.ParseInLocation(gmtPaymentLayout, s, cst)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
|
||||
func notifyToEvent(outTradeNo, tradeStatus, totalAmount string, paidAt *time.Time, raw []byte) (*provider.PaidEvent, error) {
|
||||
// 支付宝交易状态四态(sw.TradeStatus* 全集)归一化:
|
||||
// WAIT_BUYER_PAY(等待付款)→ pending;TRADE_SUCCESS/TRADE_FINISHED → succeeded;
|
||||
// TRADE_CLOSED(超时未付关闭 / 全额退款后关闭)→ failed,不留在 pending 让轮询空转到天荒地老。
|
||||
status := provider.PaidPending
|
||||
switch sw.TradeStatus(tradeStatus) {
|
||||
case sw.TradeStatusSuccess, sw.TradeStatusFinished:
|
||||
status = provider.PaidSucceeded
|
||||
case sw.TradeStatusClosed:
|
||||
status = provider.PaidFailed
|
||||
}
|
||||
var minor int64
|
||||
if totalAmount != "" {
|
||||
m, err := money.Parse(totalAmount, "CNY")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay: 金额解析失败 %q: %w", totalAmount, err)
|
||||
}
|
||||
minor = m
|
||||
}
|
||||
return &provider.PaidEvent{
|
||||
ProviderRef: outTradeNo,
|
||||
Status: status,
|
||||
PaidAmountMinor: minor,
|
||||
PaidCurrency: "CNY",
|
||||
Raw: string(raw),
|
||||
PaidAt: paidAt,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package alipay_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sw "github.com/smartwalle/alipay/v3"
|
||||
|
||||
"github.com/wangjia/pay/internal/provider"
|
||||
ali "github.com/wangjia/pay/internal/provider/alipay"
|
||||
)
|
||||
|
||||
// 生成一对 RSA 密钥:appPriv 供 client 下单签名;aliPriv/aliPub 冒充"支付宝侧"给异步通知签名/验签。
|
||||
//
|
||||
// ⚠️ 与 brief 草稿的差异(执行时发现,已按 SDK 实际要求修正):
|
||||
// alipay 公钥 client.LoadAliPayPublicKey 内部走 ncrypto.DecodePublicKey(...).PKIX(),
|
||||
// 要求 PKIX(SubjectPublicKeyInfo)编码,不是 PKCS1 —— 用 MarshalPKCS1PublicKey 会在
|
||||
// LoadAliPayPublicKey 报 "x509: failed to parse public key (use ParsePKCS1PublicKey
|
||||
// instead for this key format)"。改用 x509.MarshalPKIXPublicKey。app 私钥走
|
||||
// ncrypto.DecodePrivateKey(...).PKCS1(),失败再退 PKCS8,MarshalPKCS1PrivateKey 不受影响。
|
||||
func genKeys(t *testing.T) (appPrivPKCS1B64, aliPrivPKCS1B64, aliPubPKIXB64 string) {
|
||||
t.Helper()
|
||||
app, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
aliK, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
b64 := func(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
|
||||
appPrivPKCS1B64 = b64(x509.MarshalPKCS1PrivateKey(app))
|
||||
aliPrivPKCS1B64 = b64(x509.MarshalPKCS1PrivateKey(aliK))
|
||||
aliPubDER, err := x509.MarshalPKIXPublicKey(&aliK.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal ali pub: %v", err)
|
||||
}
|
||||
aliPubPKIXB64 = b64(aliPubDER)
|
||||
return
|
||||
}
|
||||
|
||||
func buildClient(t *testing.T, appPriv, aliPub string) *sw.Client {
|
||||
c, err := sw.New("2021000000000000", appPriv, false) // 沙箱
|
||||
if err != nil {
|
||||
t.Fatalf("new client: %v", err)
|
||||
}
|
||||
if err := c.LoadAliPayPublicKey(aliPub); err != nil {
|
||||
t.Fatalf("load pub: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestCreateRedirect(t *testing.T) {
|
||||
appPriv, _, aliPub := genKeys(t)
|
||||
p := ali.New(buildClient(t, appPriv, aliPub))
|
||||
|
||||
sess, err := p.Create(context.Background(), provider.CreateRequest{
|
||||
OutTradeNo: "PAY-1", Subject: "Pro 年付", AmountMinor: 19900, Currency: "CNY",
|
||||
ReturnURL: "https://x/return",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if sess.RenderType != provider.RenderRedirect || sess.ProviderRef != "PAY-1" {
|
||||
t.Fatalf("session = %+v", sess)
|
||||
}
|
||||
u, _ := sess.Payload["url"].(string)
|
||||
if !strings.Contains(u, "alipay") {
|
||||
t.Fatalf("url = %q 不像收银台跳转", u)
|
||||
}
|
||||
}
|
||||
|
||||
// 用"支付宝侧"私钥给一份通知表单签名,adapter 用装的公钥验签 → 归一化 PaidEvent。
|
||||
func TestVerifyCallbackRSA(t *testing.T) {
|
||||
appPriv, aliPriv, aliPub := genKeys(t)
|
||||
p := ali.New(buildClient(t, appPriv, aliPub))
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("out_trade_no", "PAY-1")
|
||||
form.Set("trade_no", "2021AAA")
|
||||
form.Set("trade_status", "TRADE_SUCCESS")
|
||||
form.Set("total_amount", "199.00")
|
||||
form.Set("sign_type", "RSA2")
|
||||
form.Set("sign", signRSA2(t, aliPriv, form))
|
||||
|
||||
ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{Raw: []byte(form.Encode())})
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if ev.ProviderRef != "PAY-1" || ev.Status != provider.PaidSucceeded ||
|
||||
ev.PaidAmountMinor != 19900 || ev.PaidCurrency != "CNY" {
|
||||
t.Fatalf("event = %+v", ev)
|
||||
}
|
||||
// brief 草稿的回调表单未带 gmt_payment;PaidAt 应留 nil,交 settle 按 D4-A3 兜底用收到时间。
|
||||
if ev.PaidAt != nil {
|
||||
t.Fatalf("未带 gmt_payment 时 PaidAt 应为 nil, got %v", ev.PaidAt)
|
||||
}
|
||||
}
|
||||
|
||||
// D4-A3:VerifyCallback 应从支付宝回调的 gmt_payment(北京时间,无时区位)取真实付款时间,
|
||||
// 而不是让 settle 一律回退到"收到时间"——对账时才能跟渠道流水的付款时点对得上。
|
||||
func TestVerifyCallbackRSA_PaidAtFromGmtPayment(t *testing.T) {
|
||||
appPriv, aliPriv, aliPub := genKeys(t)
|
||||
p := ali.New(buildClient(t, appPriv, aliPub))
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("out_trade_no", "PAY-2")
|
||||
form.Set("trade_no", "2021BBB")
|
||||
form.Set("trade_status", "TRADE_SUCCESS")
|
||||
form.Set("total_amount", "1.00")
|
||||
form.Set("gmt_payment", "2026-07-10 15:04:05")
|
||||
form.Set("sign_type", "RSA2")
|
||||
form.Set("sign", signRSA2(t, aliPriv, form))
|
||||
|
||||
ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{Raw: []byte(form.Encode())})
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if ev.PaidAt == nil {
|
||||
t.Fatal("带 gmt_payment 时 PaidAt 不应为 nil")
|
||||
}
|
||||
want := time.Date(2026, 7, 10, 15, 4, 5, 0, time.FixedZone("CST", 8*3600))
|
||||
if !ev.PaidAt.Equal(want) {
|
||||
t.Fatalf("PaidAt = %v, want %v", ev.PaidAt, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TRADE_CLOSED(超时未付关闭/全额退款后关闭)须归一化为 failed 终态,不能停在 pending
|
||||
// 让 SyncPendingAttempts 空转到订单永远"待处理"。
|
||||
func TestVerifyCallbackRSA_ClosedIsFailed(t *testing.T) {
|
||||
appPriv, aliPriv, aliPub := genKeys(t)
|
||||
p := ali.New(buildClient(t, appPriv, aliPub))
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("out_trade_no", "PAY-3")
|
||||
form.Set("trade_no", "2021CCC")
|
||||
form.Set("trade_status", "TRADE_CLOSED")
|
||||
form.Set("total_amount", "1.00")
|
||||
form.Set("sign_type", "RSA2")
|
||||
form.Set("sign", signRSA2(t, aliPriv, form))
|
||||
|
||||
ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{Raw: []byte(form.Encode())})
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if ev.Status != provider.PaidFailed {
|
||||
t.Fatalf("TRADE_CLOSED 应归一化为 PaidFailed, got %v", ev.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// signRSA2 复刻支付宝签名:排序非空参数(排除 sign/sign_type),k=v&拼接,RSA-SHA256,base64。
|
||||
func signRSA2(t *testing.T, aliPrivB64 string, form url.Values) string {
|
||||
t.Helper()
|
||||
der, _ := base64.StdEncoding.DecodeString(aliPrivB64)
|
||||
priv, err := x509.ParsePKCS1PrivateKey(der)
|
||||
if err != nil {
|
||||
t.Fatalf("parse ali priv: %v", err)
|
||||
}
|
||||
keys := make([]string, 0, len(form))
|
||||
for k := range form {
|
||||
if k == "sign" || k == "sign_type" || form.Get(k) == "" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var parts []string
|
||||
for _, k := range keys {
|
||||
parts = append(parts, k+"="+form.Get(k))
|
||||
}
|
||||
h := sha256.Sum256([]byte(strings.Join(parts, "&")))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, h[:])
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(sig)
|
||||
}
|
||||
|
||||
var _ = pem.Encode // 避免 import 未用(若不需要 pem 可删该 import 与本行)
|
||||
Reference in New Issue
Block a user