Merge pull request #2541 from DanisJiang/fix/alipay-pagepay-qrcode
fix(payment): 修复支付宝官方扫码二维码生成错误
This commit is contained in:
commit
d1910751b6
@ -105,10 +105,16 @@ func (a *Alipay) MerchantIdentityMetadata() map[string]string {
|
|||||||
|
|
||||||
// CreatePayment creates an Alipay payment using the following routing:
|
// CreatePayment creates an Alipay payment using the following routing:
|
||||||
// - Mobile (H5): alipay.trade.wap.pay — browser redirect into Alipay.
|
// - Mobile (H5): alipay.trade.wap.pay — browser redirect into Alipay.
|
||||||
// - Desktop: prefer alipay.trade.precreate to get a scan payload directly.
|
// - Desktop, default: prefer alipay.trade.precreate (FACE_TO_FACE_PAYMENT) to
|
||||||
// - Desktop fallback: if precreate is unavailable for the merchant, fall back
|
// get a scannable QR payload. If precreate is unavailable for the merchant,
|
||||||
// to alipay.trade.page.pay and expose both pay_url and qr_code so the
|
// fall back to alipay.trade.page.pay and expose pay_url only — the frontend
|
||||||
// frontend can render a QR while still allowing direct page open.
|
// opens the Alipay checkout in a new tab.
|
||||||
|
// - Desktop, paymentMode == "redirect": skip precreate and go straight to
|
||||||
|
// alipay.trade.page.pay so the frontend always opens the Alipay checkout
|
||||||
|
// in a new tab. Use this when the merchant has not enabled FACE_TO_FACE_PAYMENT.
|
||||||
|
//
|
||||||
|
// Note: alipay.trade.page.pay returns a checkout page URL, not a scannable
|
||||||
|
// payment QR. Never expose it via the QRCode field.
|
||||||
func (a *Alipay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
func (a *Alipay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
||||||
client, err := a.getClient()
|
client, err := a.getClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -150,6 +156,13 @@ func (a *Alipay) createWapTrade(client *alipay.Client, req payment.CreatePayment
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Alipay) createDesktopTrade(ctx context.Context, client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
|
func (a *Alipay) createDesktopTrade(ctx context.Context, client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
|
||||||
|
// Explicit redirect mode: merchant opted into "always open the Alipay
|
||||||
|
// checkout page in a new tab" via the provider instance's payment_mode.
|
||||||
|
// Skip precreate to avoid a wasted API call.
|
||||||
|
if strings.EqualFold(strings.TrimSpace(a.config["paymentMode"]), "redirect") {
|
||||||
|
return a.createPagePayTrade(client, req, notifyURL, returnURL)
|
||||||
|
}
|
||||||
|
|
||||||
resp, precreateErr := a.createPrecreateTrade(ctx, client, req, notifyURL)
|
resp, precreateErr := a.createPrecreateTrade(ctx, client, req, notifyURL)
|
||||||
if precreateErr == nil {
|
if precreateErr == nil {
|
||||||
return resp, nil
|
return resp, nil
|
||||||
@ -204,10 +217,12 @@ func (a *Alipay) createPagePayTrade(client *alipay.Client, req payment.CreatePay
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("alipay TradePagePay: %w", err)
|
return nil, fmt.Errorf("alipay TradePagePay: %w", err)
|
||||||
}
|
}
|
||||||
|
// Only PayURL is exposed: alipay.trade.page.pay returns a checkout page URL
|
||||||
|
// that must be opened in a browser, not a scannable payment QR. Setting it
|
||||||
|
// as QRCode would let the frontend render an unscannable image.
|
||||||
return &payment.CreatePaymentResponse{
|
return &payment.CreatePaymentResponse{
|
||||||
TradeNo: req.OrderID,
|
TradeNo: req.OrderID,
|
||||||
PayURL: payURL.String(),
|
PayURL: payURL.String(),
|
||||||
QRCode: payURL.String(),
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -189,8 +189,63 @@ func TestCreateTradeUsesPagePayForDesktop(t *testing.T) {
|
|||||||
if resp.PayURL == "" {
|
if resp.PayURL == "" {
|
||||||
t.Fatal("expected pay_url for desktop page pay")
|
t.Fatal("expected pay_url for desktop page pay")
|
||||||
}
|
}
|
||||||
if resp.QRCode != resp.PayURL {
|
// page.pay returns a checkout page URL, not a scannable QR payload —
|
||||||
t.Fatalf("qr_code = %q, want same as pay_url %q", resp.QRCode, resp.PayURL)
|
// it must never be exposed via QRCode (the frontend would render an
|
||||||
|
// unscannable image from it).
|
||||||
|
if resp.QRCode != "" {
|
||||||
|
t.Fatalf("qr_code = %q, want empty for page pay", resp.QRCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the provider instance is configured with paymentMode == "redirect",
|
||||||
|
// the desktop flow must skip precreate and go straight to page.pay.
|
||||||
|
func TestCreateTradeRedirectModeSkipsPrecreate(t *testing.T) {
|
||||||
|
origPreCreate := alipayTradePreCreate
|
||||||
|
origPagePay := alipayTradePagePay
|
||||||
|
t.Cleanup(func() {
|
||||||
|
alipayTradePreCreate = origPreCreate
|
||||||
|
alipayTradePagePay = origPagePay
|
||||||
|
})
|
||||||
|
|
||||||
|
preCreateCalls := 0
|
||||||
|
pagePayCalls := 0
|
||||||
|
alipayTradePreCreate = func(ctx context.Context, client *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
|
||||||
|
preCreateCalls++
|
||||||
|
return &alipay.TradePreCreateRsp{
|
||||||
|
Error: alipay.Error{Code: alipay.CodeSuccess},
|
||||||
|
QRCode: "https://qr.alipay.example.com/precreate-token",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) {
|
||||||
|
pagePayCalls++
|
||||||
|
if param.ProductCode != alipayProductCodePagePay {
|
||||||
|
t.Fatalf("product_code = %q, want %q", param.ProductCode, alipayProductCodePagePay)
|
||||||
|
}
|
||||||
|
return url.Parse("https://openapi.alipay.com/gateway.do?page-pay")
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := &Alipay{
|
||||||
|
config: map[string]string{"paymentMode": "redirect"},
|
||||||
|
}
|
||||||
|
resp, err := provider.createDesktopTrade(context.Background(), &alipay.Client{}, payment.CreatePaymentRequest{
|
||||||
|
OrderID: "sub2_103",
|
||||||
|
Amount: "12.00",
|
||||||
|
Subject: "Balance recharge",
|
||||||
|
}, "https://merchant.example.com/api/v1/payment/webhook/alipay", "https://merchant.example.com/payment/result")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if preCreateCalls != 0 {
|
||||||
|
t.Fatalf("precreate calls = %d, want 0 (redirect mode must skip precreate)", preCreateCalls)
|
||||||
|
}
|
||||||
|
if pagePayCalls != 1 {
|
||||||
|
t.Fatalf("page pay calls = %d, want 1", pagePayCalls)
|
||||||
|
}
|
||||||
|
if resp.PayURL == "" {
|
||||||
|
t.Fatal("expected pay_url for redirect mode")
|
||||||
|
}
|
||||||
|
if resp.QRCode != "" {
|
||||||
|
t.Fatalf("qr_code = %q, want empty for redirect mode", resp.QRCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -34,7 +34,7 @@
|
|||||||
<ToggleSwitch :label="t('common.enabled')" :checked="form.enabled" @toggle="form.enabled = !form.enabled" />
|
<ToggleSwitch :label="t('common.enabled')" :checked="form.enabled" @toggle="form.enabled = !form.enabled" />
|
||||||
<ToggleSwitch :label="t('admin.settings.payment.refundEnabled')" :checked="form.refund_enabled" @toggle="form.refund_enabled = !form.refund_enabled; if (!form.refund_enabled) form.allow_user_refund = false" />
|
<ToggleSwitch :label="t('admin.settings.payment.refundEnabled')" :checked="form.refund_enabled" @toggle="form.refund_enabled = !form.refund_enabled; if (!form.refund_enabled) form.allow_user_refund = false" />
|
||||||
<ToggleSwitch v-if="form.refund_enabled" :label="t('admin.settings.payment.allowUserRefund')" :checked="form.allow_user_refund" @toggle="form.allow_user_refund = !form.allow_user_refund" />
|
<ToggleSwitch v-if="form.refund_enabled" :label="t('admin.settings.payment.allowUserRefund')" :checked="form.allow_user_refund" @toggle="form.allow_user_refund = !form.allow_user_refund" />
|
||||||
<div v-if="form.provider_key === 'easypay'" class="flex items-center gap-2">
|
<div v-if="supportsPaymentMode" class="flex items-center gap-2">
|
||||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.paymentMode') }}</span>
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.paymentMode') }}</span>
|
||||||
<div class="flex gap-1.5">
|
<div class="flex gap-1.5">
|
||||||
<button
|
<button
|
||||||
@ -278,11 +278,37 @@ import {
|
|||||||
WEBHOOK_PATHS,
|
WEBHOOK_PATHS,
|
||||||
PAYMENT_MODE_QRCODE,
|
PAYMENT_MODE_QRCODE,
|
||||||
PAYMENT_MODE_POPUP,
|
PAYMENT_MODE_POPUP,
|
||||||
|
PAYMENT_MODE_REDIRECT,
|
||||||
STRIPE_SDK_API_VERSION,
|
STRIPE_SDK_API_VERSION,
|
||||||
getAvailableTypes,
|
getAvailableTypes,
|
||||||
extractBaseUrl,
|
extractBaseUrl,
|
||||||
} from './providerConfig'
|
} from './providerConfig'
|
||||||
|
|
||||||
|
/** Default payment_mode per provider key — "" means "no preference, use
|
||||||
|
* provider's built-in default behavior". */
|
||||||
|
function defaultPaymentMode(providerKey: string): string {
|
||||||
|
if (providerKey === 'easypay') return PAYMENT_MODE_QRCODE
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provider keys whose admin UI exposes a payment_mode selector.
|
||||||
|
* Other providers always send payment_mode = ''. */
|
||||||
|
function providerSupportsPaymentMode(providerKey: string): boolean {
|
||||||
|
return providerKey === 'easypay' || providerKey === 'alipay'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Allowed payment_mode values per provider. Used to coerce DB values
|
||||||
|
* from a different provider (or stale data) back to the default. */
|
||||||
|
function isValidPaymentMode(providerKey: string, mode: string): boolean {
|
||||||
|
if (providerKey === 'easypay') {
|
||||||
|
return mode === PAYMENT_MODE_QRCODE || mode === PAYMENT_MODE_POPUP
|
||||||
|
}
|
||||||
|
if (providerKey === 'alipay') {
|
||||||
|
return mode === '' || mode === PAYMENT_MODE_REDIRECT
|
||||||
|
}
|
||||||
|
return mode === ''
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
show: boolean
|
show: boolean
|
||||||
saving: boolean
|
saving: boolean
|
||||||
@ -359,7 +385,17 @@ const providerWebhookHint = computed(() =>
|
|||||||
|
|
||||||
const callbackPaths = computed(() => PROVIDER_CALLBACK_PATHS[form.provider_key] || null)
|
const callbackPaths = computed(() => PROVIDER_CALLBACK_PATHS[form.provider_key] || null)
|
||||||
|
|
||||||
|
const supportsPaymentMode = computed(() => providerSupportsPaymentMode(form.provider_key))
|
||||||
|
|
||||||
const paymentModeOptions = computed(() => {
|
const paymentModeOptions = computed(() => {
|
||||||
|
if (form.provider_key === 'alipay') {
|
||||||
|
// For Alipay official: "" = default (precreate → page.pay fallback);
|
||||||
|
// "redirect" = always open the Alipay checkout page in a new tab.
|
||||||
|
return [
|
||||||
|
{ value: '', label: t('admin.settings.payment.modeQRCode') },
|
||||||
|
{ value: PAYMENT_MODE_REDIRECT, label: t('admin.settings.payment.modeRedirect') },
|
||||||
|
]
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
{ value: PAYMENT_MODE_QRCODE, label: t('admin.settings.payment.modeQRCode') },
|
{ value: PAYMENT_MODE_QRCODE, label: t('admin.settings.payment.modeQRCode') },
|
||||||
{ value: PAYMENT_MODE_POPUP, label: t('admin.settings.payment.modePopup') },
|
{ value: PAYMENT_MODE_POPUP, label: t('admin.settings.payment.modePopup') },
|
||||||
@ -476,6 +512,7 @@ function toggleType(type: string) {
|
|||||||
|
|
||||||
function onKeyChange() {
|
function onKeyChange() {
|
||||||
form.supported_types = [...(PROVIDER_SUPPORTED_TYPES[form.provider_key] || [])]
|
form.supported_types = [...(PROVIDER_SUPPORTED_TYPES[form.provider_key] || [])]
|
||||||
|
form.payment_mode = defaultPaymentMode(form.provider_key)
|
||||||
clearConfig()
|
clearConfig()
|
||||||
applyDefaults()
|
applyDefaults()
|
||||||
}
|
}
|
||||||
@ -591,7 +628,7 @@ function handleSave() {
|
|||||||
name: form.name,
|
name: form.name,
|
||||||
supported_types: form.supported_types,
|
supported_types: form.supported_types,
|
||||||
enabled: form.enabled,
|
enabled: form.enabled,
|
||||||
payment_mode: form.provider_key === 'easypay' ? form.payment_mode : '',
|
payment_mode: supportsPaymentMode.value ? form.payment_mode : '',
|
||||||
refund_enabled: form.refund_enabled,
|
refund_enabled: form.refund_enabled,
|
||||||
allow_user_refund: form.refund_enabled ? form.allow_user_refund : false,
|
allow_user_refund: form.refund_enabled ? form.allow_user_refund : false,
|
||||||
config: filteredConfig,
|
config: filteredConfig,
|
||||||
@ -611,7 +648,7 @@ function reset(defaultKey: string) {
|
|||||||
form.provider_key = defaultKey
|
form.provider_key = defaultKey
|
||||||
form.supported_types = [...(PROVIDER_SUPPORTED_TYPES[defaultKey] || [])]
|
form.supported_types = [...(PROVIDER_SUPPORTED_TYPES[defaultKey] || [])]
|
||||||
form.enabled = true
|
form.enabled = true
|
||||||
form.payment_mode = defaultKey === 'easypay' ? PAYMENT_MODE_QRCODE : ''
|
form.payment_mode = defaultPaymentMode(defaultKey)
|
||||||
form.refund_enabled = false
|
form.refund_enabled = false
|
||||||
form.allow_user_refund = false
|
form.allow_user_refund = false
|
||||||
clearConfig()
|
clearConfig()
|
||||||
@ -623,7 +660,12 @@ function loadProvider(provider: ProviderInstance) {
|
|||||||
form.provider_key = provider.provider_key
|
form.provider_key = provider.provider_key
|
||||||
form.supported_types = provider.supported_types
|
form.supported_types = provider.supported_types
|
||||||
form.enabled = provider.enabled
|
form.enabled = provider.enabled
|
||||||
form.payment_mode = provider.payment_mode || (provider.provider_key === 'easypay' ? PAYMENT_MODE_QRCODE : '')
|
// Coerce to a valid value for this provider. Guards against stale data
|
||||||
|
// (e.g. "popup" written by an older client) showing up as an unselected
|
||||||
|
// button in the dialog.
|
||||||
|
form.payment_mode = isValidPaymentMode(provider.provider_key, provider.payment_mode || '')
|
||||||
|
? (provider.payment_mode || '')
|
||||||
|
: defaultPaymentMode(provider.provider_key)
|
||||||
form.refund_enabled = provider.refund_enabled
|
form.refund_enabled = provider.refund_enabled
|
||||||
form.allow_user_refund = provider.allow_user_refund
|
form.allow_user_refund = provider.allow_user_refund
|
||||||
clearConfig()
|
clearConfig()
|
||||||
|
|||||||
@ -69,7 +69,7 @@ import Icon from '@/components/icons/Icon.vue'
|
|||||||
import ToggleSwitch from './ToggleSwitch.vue'
|
import ToggleSwitch from './ToggleSwitch.vue'
|
||||||
import type { ProviderInstance } from '@/types/payment'
|
import type { ProviderInstance } from '@/types/payment'
|
||||||
import type { TypeOption } from './providerConfig'
|
import type { TypeOption } from './providerConfig'
|
||||||
import { PAYMENT_MODE_QRCODE, PAYMENT_MODE_POPUP } from './providerConfig'
|
import { PAYMENT_MODE_QRCODE, PAYMENT_MODE_POPUP, PAYMENT_MODE_REDIRECT } from './providerConfig'
|
||||||
|
|
||||||
const PROVIDER_KEY_LABELS: Record<string, string> = {
|
const PROVIDER_KEY_LABELS: Record<string, string> = {
|
||||||
easypay: 'admin.settings.payment.providerEasypay',
|
easypay: 'admin.settings.payment.providerEasypay',
|
||||||
@ -99,6 +99,7 @@ const keyLabel = computed(() => t(PROVIDER_KEY_LABELS[props.provider.provider_ke
|
|||||||
const modeLabel = computed(() => {
|
const modeLabel = computed(() => {
|
||||||
if (props.provider.payment_mode === PAYMENT_MODE_QRCODE) return t('admin.settings.payment.modeQRCode')
|
if (props.provider.payment_mode === PAYMENT_MODE_QRCODE) return t('admin.settings.payment.modeQRCode')
|
||||||
if (props.provider.payment_mode === PAYMENT_MODE_POPUP) return t('admin.settings.payment.modePopup')
|
if (props.provider.payment_mode === PAYMENT_MODE_POPUP) return t('admin.settings.payment.modePopup')
|
||||||
|
if (props.provider.payment_mode === PAYMENT_MODE_REDIRECT) return t('admin.settings.payment.modeRedirect')
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -47,6 +47,11 @@ export const METHOD_ORDER = ['alipay', 'alipay_direct', 'wxpay', 'wxpay_direct',
|
|||||||
/** Payment mode constants */
|
/** Payment mode constants */
|
||||||
export const PAYMENT_MODE_QRCODE = 'qrcode'
|
export const PAYMENT_MODE_QRCODE = 'qrcode'
|
||||||
export const PAYMENT_MODE_POPUP = 'popup'
|
export const PAYMENT_MODE_POPUP = 'popup'
|
||||||
|
/** Alipay-only: skip FACE_TO_FACE_PAYMENT precreate and open the Alipay
|
||||||
|
* checkout page in a new tab instead. Backend `alipay.go` matches on this
|
||||||
|
* literal (case-insensitive); other values fall back to the default
|
||||||
|
* precreate→pagepay flow. */
|
||||||
|
export const PAYMENT_MODE_REDIRECT = 'redirect'
|
||||||
|
|
||||||
export const PAYMENT_CURRENCY_OPTIONS: TypeOption[] = [
|
export const PAYMENT_CURRENCY_OPTIONS: TypeOption[] = [
|
||||||
{ value: 'CNY', label: 'CNY' },
|
{ value: 'CNY', label: 'CNY' },
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user