Files
pay/internal/provider/alipay/alipay.go
T
wangjia 8051b0fb16 fix(v2): jiu 反馈波——端型透传(wap/page)+ alipay qr(当面付移植)+ 单号增熵/挡板限流
1. CreateOrderInput/RetryOrder 加 Metadata 通道,handler 白名单过滤(is_mobile/render)
   后原样传给 provider.CreateRequest;alipay adapter 据 is_mobile 选 wap/page。
2. alipay adapter 移植 v1 当面付(TradePreCreate):Metadata["render"]=="qr" → 二维码
   render_type,payload={qr_content,display_amount,currency},默认 2 小时窗口。
3. NewOutTradeNo 随机部分 8→16 hex 防生日碰撞(仍 <=64 字符,合规 DB 列/支付宝上限);
   v2 改状态端点(下单/重试/取消)加 per-IP 内存令牌桶限流,默认开 30/min,
   config.rate_limit.disabled 可关;callback/GET 查询不限。

顺带修:internal/reconcile/sync_test.go 的 gateway.New 调用漏传 refunds store,
预先存在的编译期回归(与本次改动无关,但挡住 go test ./... 全绿,一并修掉)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-10 18:40:15 +08:00

226 lines
8.8 KiB
Go

// 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"
"errors"
"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)
// qrDefaultTTL 当面付(TradePreCreate)未显式设置 timeout_express 时,支付宝按
// 官方文档默认 2 小时未支付自动关闭交易;这里镜像同一默认值给客户端展示倒计时用,
// 不代表 pay 侧另设了过期逻辑(真实过期仍由支付宝侧判定,pay 靠回调/查单收敛)。
const qrDefaultTTL = 2 * time.Hour
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, provider.RenderQR},
SupportsRefund: true, // P4:alipay.trade.refund 同步退款
SettleCurrencies: []string{"CNY"},
Regions: []string{"cn"},
}
}
// Refund 调 alipay.trade.refund(同步接口)。providerRef=out_trade_no(alipay 归位键),
// refundID 作 out_request_no(部分退款必传且须唯一稳定 → 幂等)。IsFailure()==false 即成功
// (FundChange=N 表重复退款已幂等,仍算成功)。
func (p *Provider) Refund(ctx context.Context, providerRef, refundID string, amountMinor int64, reason string) (string, provider.PaidStatus, error) {
amt, err := money.Format(amountMinor, "CNY")
if err != nil {
return "", provider.PaidFailed, err
}
rsp, err := p.client.TradeRefund(ctx, sw.TradeRefund{
OutTradeNo: providerRef,
RefundAmount: amt,
RefundReason: reason,
OutRequestNo: refundID,
})
if err != nil {
return "", provider.PaidFailed, fmt.Errorf("alipay: 退款请求失败: %w", err)
}
if rsp.IsFailure() {
// 渠道明确拒绝(如 code=40004 ACQ.TRADE_HAS_FINISHED):钱确定没退成,
// wrap ErrRefundRejected 让 gateway.Refund 能安全标 failed 释放预留额度。
// 与之相对,前面 TradeRefund 调用本身失败(超时/网络错误)不 wrap 此哨兵——
// 那种情况渠道侧是否已实际退款不确定,不能标 failed(见 item 1)。
return "", provider.PaidFailed, fmt.Errorf("alipay: 退款被拒: %w", errors.Join(provider.ErrRefundRejected, rsp.Error))
}
// 退款在支付宝侧以 out_request_no 定位;refundRef 回传我方退款单号(= out_request_no)。
return refundID, provider.PaidSucceeded, nil
}
func (p *Provider) Create(ctx 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
}
// render=qr:当面付(扫码收银台,不跳转)——移植自 v1 internal/channel/alipay.go PreCreate。
if req.Metadata["render"] == "qr" {
return p.createQR(ctx, req, amount)
}
var payURL *url.URL
if isMobileMetadata(req.Metadata) {
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
}
// isMobileMetadata 判端型:白名单值 "1"/"true" 均视为手机端(handler 层 resolveIsMobile
// 目前下发 "1",这里同时兼容 "true" 以防未来调用方改用布尔字面量字符串)。
func isMobileMetadata(md map[string]string) bool {
v := md["is_mobile"]
return v == "1" || v == "true"
}
// createQR 当面付(alipay.trade.precreate):返回二维码码串供前端渲染,不跳转、不拉起 App,
// 收银台之外的第三个 render_type(RenderQR)——与 wap/page 共用 VerifyCallback/Query
// (同一套异步通知 + trade_query,渠道侧不区分下单方式)。
func (p *Provider) createQR(ctx context.Context, req provider.CreateRequest, amount string) (*provider.Session, error) {
pc := sw.TradePreCreate{}
pc.OutTradeNo = req.OutTradeNo
pc.Subject = req.Subject
pc.TotalAmount = amount
rsp, err := p.client.TradePreCreate(ctx, pc)
if err != nil {
return nil, fmt.Errorf("alipay: 预下单调用失败: %w", err)
}
if rsp.IsFailure() {
return nil, fmt.Errorf("alipay: 预下单失败: %s / %s", rsp.Msg, rsp.SubMsg)
}
exp := time.Now().Add(qrDefaultTTL)
return &provider.Session{
ProviderRef: req.OutTradeNo, // 支付宝以 out_trade_no 归位,与 wap/page 一致
RenderType: provider.RenderQR,
Payload: map[string]any{
"qr_content": rsp.QRCode,
"display_amount": amount, // 元串(非分),供前端直接展示金额
"currency": "CNY",
},
ExpiresAt: &exp,
}, 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
}