Merge pull request #6309 from fengshao1227/fix/easypay-resolve-relative-payurl-qrcode

fix(payment): EasyPay 补全 mapi.php 回传的相对 payurl/qrcode
This commit is contained in:
Wesley Liddick
2026-08-28 15:57:10 +08:00
committed by GitHub
2 changed files with 275 additions and 1 deletions
+47 -1
View File
@@ -202,7 +202,53 @@ func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymen
if req.IsMobile && resp.PayURL2 != "" {
payURL = resp.PayURL2
}
return &payment.CreatePaymentResponse{TradeNo: resp.TradeNo, PayURL: payURL, QRCode: resp.QRCode}, nil
base := e.apiBase()
return &payment.CreatePaymentResponse{
TradeNo: resp.TradeNo,
PayURL: resolveEasyPayReturnedRef(base, payURL),
QRCode: resolveEasyPayReturnedRef(base, resp.QRCode),
}, nil
}
// resolveEasyPayReturnedRef absolutizes a payurl/payurl2/qrcode reference that
// mapi.php returned, resolving it against the instance's configured apiBase.
//
// EasyPay-compatible upstreams disagree on this field: some answer with a full
// checkout URL, others with a site-root-relative path such as
// "/api/pay/toapp/<order>". createRedirectPayment already builds an absolute URL
// itself (apiBase + "/submit.php?..."), but the mapi.php branch stored whatever
// came back verbatim, and nothing downstream repairs it —
// sanitizeCreatePaymentResponseDetails only strips NUL bytes before the value is
// persisted to pay_url/qr_code. The frontend then feeds qr_code straight into
// QRCode.toCanvas (PaymentQRCodeView.renderQR), so a relative path becomes a QR
// whose payload is a bare path: WeChat renders it as text, and pay_url resolves
// against the gateway's own domain and 404s.
//
// That is precisely the outcome the Alipay provider already refuses to produce —
// "Setting it as QRCode would let the frontend render an unscannable image"
// (createPagePayTrade) — so EasyPay is brought in line with the same rule.
//
// Only references beginning with "/" are resolved. Anything carrying a scheme is
// already actionable and is returned untouched, which covers both absolute
// https:// checkout URLs and app deep links (weixin://, wxp://, alipays://). A
// reference without a leading slash is left alone as well: QR payloads are
// frequently opaque tokens rather than paths, and rewriting "OrderToken123" into
// "<apiBase>/OrderToken123" would break a payload that works today — strictly
// worse than the bug this fixes.
func resolveEasyPayReturnedRef(apiBase, ref string) string {
trimmed := strings.TrimSpace(ref)
if !strings.HasPrefix(trimmed, "/") {
return ref
}
base, err := url.Parse(strings.TrimSpace(apiBase))
if err != nil || base.Scheme == "" || base.Host == "" {
return ref
}
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Scheme != "" {
return ref
}
return base.ResolveReference(parsed).String()
}
// resolveURLs returns (notifyURL, returnURL) preferring request values,
@@ -0,0 +1,228 @@
package provider
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/payment"
)
// issue #6292: mapi.php 上游若回相对形式的 payurl/payurl2/qrcode,网关原样
// 落库,前端把 qr_code 直接喂给 QRCode.toCanvas,扫出来是一段裸路径;pay_url 则在
// 网关自己的域名下 404。这里锁定归一化规则与全部「不得改写」的边界。
func TestResolveEasyPayReturnedRef(t *testing.T) {
t.Parallel()
const apiBase = "https://pay.example.com/xpay/epay"
tests := []struct {
name string
apiBase string
ref string
want string
}{
{
// issue 复现主线:站点根相对路径按 apiBase 的 origin 补全,
// apiBase 自身的 /xpay/epay 前缀不参与(根相对语义)。
name: "root relative path resolves against origin",
apiBase: apiBase,
ref: "/api/pay/toapp/ORDER_ID",
want: "https://pay.example.com/api/pay/toapp/ORDER_ID",
},
{
name: "root relative path keeps query string",
apiBase: apiBase,
ref: "/api/pay/toapp?oid=ORDER_ID&t=1",
want: "https://pay.example.com/api/pay/toapp?oid=ORDER_ID&t=1",
},
{
name: "protocol relative reference inherits base scheme",
apiBase: apiBase,
ref: "//cashier.example.com/pay/ORDER_ID",
want: "https://cashier.example.com/pay/ORDER_ID",
},
{
name: "surrounding whitespace does not defeat resolution",
apiBase: apiBase,
ref: " /api/pay/toapp/ORDER_ID ",
want: "https://pay.example.com/api/pay/toapp/ORDER_ID",
},
{
name: "http base keeps its scheme",
apiBase: "http://pay.example.com",
ref: "/api/pay/toapp/ORDER_ID",
want: "http://pay.example.com/api/pay/toapp/ORDER_ID",
},
// —— 以下全部必须原样返回 ——
{
name: "empty stays empty",
apiBase: apiBase,
ref: "",
want: "",
},
{
name: "absolute https url is untouched",
apiBase: apiBase,
ref: "https://cashier.other.com/pay/ORDER_ID",
want: "https://cashier.other.com/pay/ORDER_ID",
},
{
name: "wechat deep link is untouched",
apiBase: apiBase,
ref: "weixin://wxpay/bizpayurl?pr=ABCdef",
want: "weixin://wxpay/bizpayurl?pr=ABCdef",
},
{
name: "alipay deep link is untouched",
apiBase: apiBase,
ref: "alipays://platformapi/startapp?saId=10000007",
want: "alipays://platformapi/startapp?saId=10000007",
},
{
name: "face-to-face wxp payload is untouched",
apiBase: apiBase,
ref: "wxp://f2f0Abc_dEfGhIjKlMnOpQrStU",
want: "wxp://f2f0Abc_dEfGhIjKlMnOpQrStU",
},
{
// 无前导斜杠的裸 token:把它改写成 <apiBase>/token 会毁掉一个本来
// 可用的二维码载荷,比 bug 本身更糟,所以不碰。
name: "opaque token without leading slash is untouched",
apiBase: apiBase,
ref: "OrderToken123",
want: "OrderToken123",
},
{
name: "path-like reference without leading slash is untouched",
apiBase: apiBase,
ref: "api/pay/toapp/ORDER_ID",
want: "api/pay/toapp/ORDER_ID",
},
{
name: "empty api base leaves the reference alone",
apiBase: "",
ref: "/api/pay/toapp/ORDER_ID",
want: "/api/pay/toapp/ORDER_ID",
},
{
name: "api base without scheme leaves the reference alone",
apiBase: "pay.example.com",
ref: "/api/pay/toapp/ORDER_ID",
want: "/api/pay/toapp/ORDER_ID",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := resolveEasyPayReturnedRef(tt.apiBase, tt.ref); got != tt.want {
t.Fatalf("resolveEasyPayReturnedRef(%q, %q) = %q, want %q", tt.apiBase, tt.ref, got, tt.want)
}
})
}
}
// newEasyPayMapiStub serves mapi.php with a canned body and reports the api base
// the provider should be configured with. basePath lets a test prove that a
// root-relative reference drops the api base's own path prefix.
func newEasyPayMapiStub(t *testing.T, basePath, body string) (*EasyPay, string) {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
}))
t.Cleanup(server.Close)
apiBase := server.URL + basePath
provider, err := NewEasyPay("test-instance", map[string]string{
"pid": "pid-1",
"pkey": "pkey-1",
"apiBase": apiBase,
"notifyUrl": "https://example.com/notify",
"returnUrl": "https://example.com/return",
})
if err != nil {
t.Fatalf("NewEasyPay: %v", err)
}
return provider, server.URL
}
func TestEasyPayCreatePaymentResolvesRelativeQRCode(t *testing.T) {
t.Parallel()
provider, origin := newEasyPayMapiStub(t, "/xpay/epay",
`{"code":1,"trade_no":"TRADE_NO","payurl":"","qrcode":"/api/pay/toapp/ORDER_ID"}`)
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
OrderID: "sub2-relative-qr",
Amount: "1.00",
PaymentType: payment.TypeWxpay,
Subject: "Relative QR",
})
if err != nil {
t.Fatalf("CreatePayment: %v", err)
}
if want := origin + "/api/pay/toapp/ORDER_ID"; resp.QRCode != want {
t.Fatalf("QRCode = %q, want %q (a bare path would render as text in WeChat)", resp.QRCode, want)
}
if resp.PayURL != "" {
t.Fatalf("PayURL = %q, want empty (upstream sent none)", resp.PayURL)
}
if resp.TradeNo != "TRADE_NO" {
t.Fatalf("TradeNo = %q, want TRADE_NO", resp.TradeNo)
}
}
func TestEasyPayCreatePaymentResolvesRelativeMobilePayURL2(t *testing.T) {
t.Parallel()
provider, origin := newEasyPayMapiStub(t, "",
`{"code":1,"trade_no":"TRADE_NO","payurl":"/pc/pay/ORDER_ID","payurl2":"/h5/pay/ORDER_ID"}`)
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
OrderID: "sub2-relative-h5",
Amount: "1.00",
PaymentType: payment.TypeWxpay,
Subject: "Relative H5",
IsMobile: true,
})
if err != nil {
t.Fatalf("CreatePayment: %v", err)
}
// payurl2 优先于 payurl 的选择语义不变,只是补全了地址。
if want := origin + "/h5/pay/ORDER_ID"; resp.PayURL != want {
t.Fatalf("PayURL = %q, want %q", resp.PayURL, want)
}
}
func TestEasyPayCreatePaymentKeepsUpstreamAbsoluteAndOpaqueValues(t *testing.T) {
t.Parallel()
const (
absoluteURL = "https://cashier.example.com/pay/ORDER_ID"
deepLink = "weixin://wxpay/bizpayurl?pr=ABCdef"
)
provider, _ := newEasyPayMapiStub(t, "/xpay/epay",
`{"code":1,"trade_no":"TRADE_NO","payurl":"`+absoluteURL+`","qrcode":"`+deepLink+`"}`)
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
OrderID: "sub2-absolute",
Amount: "1.00",
PaymentType: payment.TypeWxpay,
Subject: "Absolute",
})
if err != nil {
t.Fatalf("CreatePayment: %v", err)
}
if resp.PayURL != absoluteURL {
t.Fatalf("PayURL = %q, want %q unchanged", resp.PayURL, absoluteURL)
}
if resp.QRCode != deepLink {
t.Fatalf("QRCode = %q, want %q unchanged", resp.QRCode, deepLink)
}
}