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
|
||||
}
|
||||
Reference in New Issue
Block a user