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
This commit is contained in:
@@ -23,6 +23,11 @@ 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} }
|
||||
@@ -31,7 +36,7 @@ func (p *Provider) Method() string { return "alipay" }
|
||||
|
||||
func (p *Provider) Capabilities() provider.Capabilities {
|
||||
return provider.Capabilities{
|
||||
RenderTypes: []provider.RenderType{provider.RenderRedirect},
|
||||
RenderTypes: []provider.RenderType{provider.RenderRedirect, provider.RenderQR},
|
||||
SupportsRefund: true, // P4:alipay.trade.refund 同步退款
|
||||
SettleCurrencies: []string{"CNY"},
|
||||
Regions: []string{"cn"},
|
||||
@@ -66,7 +71,7 @@ func (p *Provider) Refund(ctx context.Context, providerRef, refundID string, amo
|
||||
return refundID, provider.PaidSucceeded, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (*provider.Session, error) {
|
||||
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)
|
||||
}
|
||||
@@ -74,8 +79,12 @@ func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (*provi
|
||||
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 req.Metadata["is_mobile"] == "1" {
|
||||
if isMobileMetadata(req.Metadata) {
|
||||
wp := sw.TradeWapPay{}
|
||||
wp.OutTradeNo = req.OutTradeNo
|
||||
wp.Subject = req.Subject
|
||||
@@ -103,6 +112,41 @@ func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (*provi
|
||||
}, 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 {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package alipay_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"crypto"
|
||||
|
||||
"github.com/wangjia/pay/internal/provider"
|
||||
ali "github.com/wangjia/pay/internal/provider/alipay"
|
||||
)
|
||||
|
||||
// jiu 反馈波 item 1:Metadata["is_mobile"]=="1" 应走 TradeWapPay,产出与
|
||||
// page(FAST_INSTANT_TRADE_PAY)不同的收银台跳转(product_code=QUICK_WAP_WAY)。
|
||||
// TradeWapPay/TradePagePay 都是纯本地签名构造 URL(不出网),同 alipay_test.go
|
||||
// TestCreateRedirect 的用法,无需 httptest。
|
||||
func TestCreateWapDiffersFromPage(t *testing.T) {
|
||||
appPriv, _, aliPub := genKeys(t)
|
||||
p := ali.New(buildClient(t, appPriv, aliPub))
|
||||
|
||||
page, err := p.Create(context.Background(), provider.CreateRequest{
|
||||
OutTradeNo: "PAY-WAP-1", Subject: "Pro 年付", AmountMinor: 19900, Currency: "CNY",
|
||||
ReturnURL: "https://x/return",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create page: %v", err)
|
||||
}
|
||||
wap, err := p.Create(context.Background(), provider.CreateRequest{
|
||||
OutTradeNo: "PAY-WAP-1", Subject: "Pro 年付", AmountMinor: 19900, Currency: "CNY",
|
||||
ReturnURL: "https://x/return", Metadata: map[string]string{"is_mobile": "1"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create wap: %v", err)
|
||||
}
|
||||
if page.RenderType != provider.RenderRedirect || wap.RenderType != provider.RenderRedirect {
|
||||
t.Fatalf("render_type: page=%v wap=%v, want both redirect", page.RenderType, wap.RenderType)
|
||||
}
|
||||
pageURL, _ := page.Payload["url"].(string)
|
||||
wapURL, _ := wap.Payload["url"].(string)
|
||||
if pageURL == wapURL {
|
||||
t.Fatalf("wap URL 应与 page URL 不同(不同 product_code/签名参数), got 相同: %q", pageURL)
|
||||
}
|
||||
if !strings.Contains(wapURL, "QUICK_WAP_WAY") {
|
||||
t.Fatalf("wap URL 应带 product_code=QUICK_WAP_WAY, got %q", wapURL)
|
||||
}
|
||||
if !strings.Contains(pageURL, "FAST_INSTANT_TRADE_PAY") {
|
||||
t.Fatalf("page URL 应带 product_code=FAST_INSTANT_TRADE_PAY, got %q", pageURL)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeAlipayPreCreate 返回一份用"支付宝侧"私钥签名的 alipay.trade.precreate 响应
|
||||
// (与 alipay_refund_test.go 的 fakeAlipayRefund 同一套 fixture 手法)。
|
||||
func fakeAlipayPreCreate(t *testing.T, aliPriv *rsa.PrivateKey, qrCode 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",
|
||||
"out_trade_no": "PAY-QR-1", "qr_code": qrCode,
|
||||
}
|
||||
nodeJSON, _ := json.Marshal(node)
|
||||
h := sha256.Sum256(nodeJSON)
|
||||
sig, _ := rsa.SignPKCS1v15(rand.Reader, aliPriv, crypto.SHA256, h[:])
|
||||
resp := map[string]any{
|
||||
"alipay_trade_precreate_response": json.RawMessage(nodeJSON),
|
||||
"sign": base64.StdEncoding.EncodeToString(sig),
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
}
|
||||
|
||||
// jiu 反馈波 item 2:Metadata["render"]=="qr" 应走 TradePreCreate(当面付),
|
||||
// 产出 render_type=qr,payload 三字段 {qr_content, display_amount, currency}。
|
||||
func TestCreateQR(t *testing.T) {
|
||||
aliKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("gen ali key: %v", err)
|
||||
}
|
||||
ts := fakeAlipayPreCreate(t, aliKey, "https://qr.alipay.com/bax12345")
|
||||
defer ts.Close()
|
||||
p := ali.New(buildRefundClient(t, ts.URL, &aliKey.PublicKey)) // 复用 alipay_refund_test.go 的 sandbox-gateway client builder
|
||||
|
||||
sess, err := p.Create(context.Background(), provider.CreateRequest{
|
||||
OutTradeNo: "PAY-QR-1", Subject: "Pro 年付", AmountMinor: 19900, Currency: "CNY",
|
||||
Metadata: map[string]string{"render": "qr"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create qr: %v", err)
|
||||
}
|
||||
if sess.RenderType != provider.RenderQR {
|
||||
t.Fatalf("render_type = %v, want qr", sess.RenderType)
|
||||
}
|
||||
if sess.ExpiresAt == nil {
|
||||
t.Fatal("qr session 应带 ExpiresAt(当面付默认 2 小时窗口)")
|
||||
}
|
||||
if sess.Payload["qr_content"] != "https://qr.alipay.com/bax12345" {
|
||||
t.Fatalf("payload.qr_content = %v", sess.Payload["qr_content"])
|
||||
}
|
||||
if sess.Payload["display_amount"] != "199" { // money.Format 去尾零(19900 分 → "199")
|
||||
t.Fatalf("payload.display_amount = %v, want 199", sess.Payload["display_amount"])
|
||||
}
|
||||
if sess.Payload["currency"] != "CNY" {
|
||||
t.Fatalf("payload.currency = %v, want CNY", sess.Payload["currency"])
|
||||
}
|
||||
}
|
||||
|
||||
// alipay.trade.precreate 被渠道拒绝(如金额超限)时 Create 应报错、不返回 Session。
|
||||
func TestCreateQRChannelRejected(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.INVALID_PARAMETER", "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_precreate_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))
|
||||
|
||||
sess, err := p.Create(context.Background(), provider.CreateRequest{
|
||||
OutTradeNo: "PAY-QR-2", Subject: "Pro 年付", AmountMinor: 19900, Currency: "CNY",
|
||||
Metadata: map[string]string{"render": "qr"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("want 渠道拒绝返回 error, got session=%+v", sess)
|
||||
}
|
||||
if sess != nil {
|
||||
t.Fatalf("渠道拒绝时不应返回 Session, got %+v", sess)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ type Provider struct {
|
||||
refundRef string
|
||||
refundStatus provider.PaidStatus
|
||||
refundErr error
|
||||
|
||||
lastMetadata map[string]string // 测试缝:记录最近一次 Create 收到的 Metadata(jiu 反馈波,验证 gateway 透传)
|
||||
}
|
||||
|
||||
func New() *Provider { return &Provider{queryResults: map[string]provider.PaidEvent{}} }
|
||||
@@ -41,6 +43,9 @@ func (p *Provider) Capabilities() provider.Capabilities {
|
||||
}
|
||||
|
||||
func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (*provider.Session, error) {
|
||||
p.mu.Lock()
|
||||
p.lastMetadata = req.Metadata
|
||||
p.mu.Unlock()
|
||||
exp := time.Now().Add(15 * time.Minute)
|
||||
ref := "FAKE-" + req.OutTradeNo + "-" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
return &provider.Session{
|
||||
@@ -88,6 +93,13 @@ func (p *Provider) Query(_ context.Context, req provider.QueryRequest) (*provide
|
||||
return &provider.PaidEvent{ProviderRef: req.ProviderRef, Status: provider.PaidPending}, nil
|
||||
}
|
||||
|
||||
// LastMetadata returns the Metadata passed to the most recent Create call (test seam).
|
||||
func (p *Provider) LastMetadata() map[string]string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.lastMetadata
|
||||
}
|
||||
|
||||
// SetQueryResult primes Query to report a specific event (test seam).
|
||||
func (p *Provider) SetQueryResult(providerRef string, ev provider.PaidEvent) {
|
||||
p.mu.Lock()
|
||||
|
||||
Reference in New Issue
Block a user