diff --git a/internal/provider/alipay/alipay.go b/internal/provider/alipay/alipay.go new file mode 100644 index 0000000..ecf6dd2 --- /dev/null +++ b/internal/provider/alipay/alipay.go @@ -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 +} diff --git a/internal/provider/alipay/alipay_test.go b/internal/provider/alipay/alipay_test.go new file mode 100644 index 0000000..c61924d --- /dev/null +++ b/internal/provider/alipay/alipay_test.go @@ -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 与本行)