Files
pay/internal/provider/alipay/alipay_qr_test.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

147 lines
5.5 KiB
Go

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)
}
}