fix payment qr fallback and admin guidance

This commit is contained in:
IanShaw027
2026-04-22 07:33:14 -07:00
parent 5551349349
commit f35e967516
20 changed files with 845 additions and 43 deletions

View File

@@ -15,8 +15,9 @@ import (
// Alipay product codes.
const (
alipayProductCodeWapPay = "QUICK_WAP_WAY"
alipayProductCodePagePay = "FAST_INSTANT_TRADE_PAY"
alipayProductCodePreCreate = "FACE_TO_FACE_PAYMENT"
alipayProductCodeWapPay = "QUICK_WAP_WAY"
alipayProductCodePagePay = "FAST_INSTANT_TRADE_PAY"
)
// Alipay response constants.
@@ -30,6 +31,9 @@ var (
alipayTradeWapPay = func(client *alipay.Client, param alipay.TradeWapPay) (*url.URL, error) {
return client.TradeWapPay(param)
}
alipayTradePreCreate = func(ctx context.Context, client *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
return client.TradePreCreate(ctx, param)
}
alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) {
return client.TradePagePay(param)
}
@@ -99,13 +103,13 @@ func (a *Alipay) MerchantIdentityMetadata() map[string]string {
return map[string]string{"app_id": appID}
}
// CreatePayment creates an Alipay payment using redirect-only flow:
// - Mobile (H5): alipay.trade.wap.pay — returns a URL the browser jumps to.
// - PC: alipay.trade.page.pay — returns a gateway URL the browser opens in a
// new window; Alipay's own page then shows login/QR. We intentionally do
// NOT encode the URL into a QR on the client (it isn't a scannable payload
// and would produce an invalid scan result).
func (a *Alipay) CreatePayment(_ context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
// CreatePayment creates an Alipay payment using the following routing:
// - Mobile (H5): alipay.trade.wap.pay — browser redirect into Alipay.
// - Desktop: prefer alipay.trade.precreate to get a scan payload directly.
// - Desktop fallback: if precreate is unavailable for the merchant, fall back
// to alipay.trade.page.pay and expose both pay_url and qr_code so the
// frontend can render a QR while still allowing direct page open.
func (a *Alipay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
client, err := a.getClient()
if err != nil {
return nil, err
@@ -123,7 +127,7 @@ func (a *Alipay) CreatePayment(_ context.Context, req payment.CreatePaymentReque
if req.IsMobile {
return a.createWapTrade(client, req, notifyURL, returnURL)
}
return a.createPagePayTrade(client, req, notifyURL, returnURL)
return a.createDesktopTrade(ctx, client, req, notifyURL, returnURL)
}
func (a *Alipay) createWapTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
@@ -145,6 +149,48 @@ func (a *Alipay) createWapTrade(client *alipay.Client, req payment.CreatePayment
}, nil
}
func (a *Alipay) createDesktopTrade(ctx context.Context, client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
resp, precreateErr := a.createPrecreateTrade(ctx, client, req, notifyURL)
if precreateErr == nil {
return resp, nil
}
resp, pagePayErr := a.createPagePayTrade(client, req, notifyURL, returnURL)
if pagePayErr == nil {
return resp, nil
}
return nil, fmt.Errorf("alipay desktop payment failed: precreate=%v; pagepay=%w", precreateErr, pagePayErr)
}
func (a *Alipay) createPrecreateTrade(ctx context.Context, client *alipay.Client, req payment.CreatePaymentRequest, notifyURL string) (*payment.CreatePaymentResponse, error) {
param := alipay.TradePreCreate{}
param.OutTradeNo = req.OrderID
param.TotalAmount = req.Amount
param.Subject = req.Subject
param.ProductCode = alipayProductCodePreCreate
param.NotifyURL = notifyURL
rsp, err := alipayTradePreCreate(ctx, client, param)
if err != nil {
return nil, fmt.Errorf("alipay TradePreCreate: %w", err)
}
if rsp == nil {
return nil, fmt.Errorf("alipay TradePreCreate: empty response")
}
if rsp.IsFailure() {
return nil, fmt.Errorf("alipay TradePreCreate failed: %s", rsp.Error.Error())
}
if strings.TrimSpace(rsp.QRCode) == "" {
return nil, fmt.Errorf("alipay TradePreCreate: empty qr_code")
}
return &payment.CreatePaymentResponse{
TradeNo: req.OrderID,
QRCode: rsp.QRCode,
}, nil
}
func (a *Alipay) createPagePayTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
param := alipay.TradePagePay{}
param.OutTradeNo = req.OrderID
@@ -161,6 +207,7 @@ func (a *Alipay) createPagePayTrade(client *alipay.Client, req payment.CreatePay
return &payment.CreatePaymentResponse{
TradeNo: req.OrderID,
PayURL: payURL.String(),
QRCode: payURL.String(),
}, nil
}
@@ -192,7 +239,15 @@ func (a *Alipay) QueryOrder(ctx context.Context, tradeNo string) (*payment.Query
amount, err := strconv.ParseFloat(result.TotalAmount, 64)
if err != nil {
return nil, fmt.Errorf("alipay parse amount %q: %w", result.TotalAmount, err)
amount, err = parseAlipayAmount(
result.TotalAmount,
result.ReceiptAmount,
result.BuyerPayAmount,
result.InvoiceAmount,
)
if err != nil {
return nil, fmt.Errorf("alipay parse amount: %w", err)
}
}
return &payment.QueryOrderResponse{
@@ -228,7 +283,14 @@ func (a *Alipay) VerifyNotification(ctx context.Context, rawBody string, _ map[s
amount, err := strconv.ParseFloat(notification.TotalAmount, 64)
if err != nil {
return nil, fmt.Errorf("alipay parse notification amount %q: %w", notification.TotalAmount, err)
amount, err = parseAlipayAmount(
notification.TotalAmount,
notification.ReceiptAmount,
notification.BuyerPayAmount,
)
if err != nil {
return nil, fmt.Errorf("alipay parse notification amount: %w", err)
}
}
metadata := a.MerchantIdentityMetadata()
@@ -306,6 +368,20 @@ func isTradeNotExist(err error) bool {
return strings.Contains(err.Error(), alipayErrTradeNotExist)
}
func parseAlipayAmount(values ...string) (float64, error) {
for _, raw := range values {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
amount, err := strconv.ParseFloat(raw, 64)
if err == nil {
return amount, nil
}
}
return 0, fmt.Errorf("no valid amount field")
}
// Ensure interface compliance.
var (
_ payment.Provider = (*Alipay)(nil)

View File

@@ -3,6 +3,7 @@
package provider
import (
"context"
"errors"
"net/url"
"strings"
@@ -136,15 +137,22 @@ func TestNewAlipay(t *testing.T) {
}
func TestCreateTradeUsesPagePayForDesktop(t *testing.T) {
origPreCreate := alipayTradePreCreate
origPagePay := alipayTradePagePay
origWapPay := alipayTradeWapPay
t.Cleanup(func() {
alipayTradePreCreate = origPreCreate
alipayTradePagePay = origPagePay
alipayTradeWapPay = origWapPay
})
preCreateCalls := 0
pagePayCalls := 0
wapPayCalls := 0
alipayTradePreCreate = func(ctx context.Context, client *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
preCreateCalls++
return nil, errors.New("merchant does not have FACE_TO_FACE_PAYMENT")
}
alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) {
pagePayCalls++
if param.OutTradeNo != "sub2_100" {
@@ -161,7 +169,7 @@ func TestCreateTradeUsesPagePayForDesktop(t *testing.T) {
}
provider := &Alipay{}
resp, err := provider.createPagePayTrade(&alipay.Client{}, payment.CreatePaymentRequest{
resp, err := provider.createDesktopTrade(context.Background(), &alipay.Client{}, payment.CreatePaymentRequest{
OrderID: "sub2_100",
Amount: "88.00",
Subject: "Balance recharge",
@@ -169,6 +177,9 @@ func TestCreateTradeUsesPagePayForDesktop(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if preCreateCalls != 1 {
t.Fatalf("precreate calls = %d, want 1", preCreateCalls)
}
if pagePayCalls != 1 {
t.Fatalf("page pay calls = %d, want 1", pagePayCalls)
}
@@ -178,6 +189,9 @@ func TestCreateTradeUsesPagePayForDesktop(t *testing.T) {
if resp.PayURL == "" {
t.Fatal("expected pay_url for desktop page pay")
}
if resp.QRCode != resp.PayURL {
t.Fatalf("qr_code = %q, want same as pay_url %q", resp.QRCode, resp.PayURL)
}
}
func TestCreateTradeUsesWapPayForMobile(t *testing.T) {
@@ -213,6 +227,54 @@ func TestCreateTradeUsesWapPayForMobile(t *testing.T) {
}
}
func TestCreateTradeUsesPrecreateForDesktopWhenAvailable(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++
if param.ProductCode != alipayProductCodePreCreate {
t.Fatalf("product_code = %q, want %q", param.ProductCode, alipayProductCodePreCreate)
}
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++
return url.Parse("https://openapi.alipay.com/gateway.do?page-pay")
}
provider := &Alipay{}
resp, err := provider.createDesktopTrade(context.Background(), &alipay.Client{}, payment.CreatePaymentRequest{
OrderID: "sub2_102",
Amount: "66.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 != 1 {
t.Fatalf("precreate calls = %d, want 1", preCreateCalls)
}
if pagePayCalls != 0 {
t.Fatalf("page pay calls = %d, want 0", pagePayCalls)
}
if resp.QRCode != "https://qr.alipay.example.com/precreate-token" {
t.Fatalf("qr_code = %q", resp.QRCode)
}
if resp.PayURL != "" {
t.Fatalf("pay_url = %q, want empty for precreate", resp.PayURL)
}
}
func TestAlipayMerchantIdentityMetadata(t *testing.T) {
t.Parallel()
@@ -227,3 +289,19 @@ func TestAlipayMerchantIdentityMetadata(t *testing.T) {
t.Fatalf("app_id = %q, want %q", metadata["app_id"], "2021001234567890")
}
}
func TestParseAlipayAmount(t *testing.T) {
t.Parallel()
amount, err := parseAlipayAmount("", "88.00", "77.00")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if amount != 88 {
t.Fatalf("amount = %v, want 88", amount)
}
if _, err := parseAlipayAmount("", "not-a-number"); err == nil {
t.Fatal("expected error when no valid amount field exists")
}
}

View File

@@ -122,7 +122,7 @@ Compatible with any payment service that implements the EasyPay protocol.
### Alipay (Direct)
Direct integration with Alipay Open Platform. Desktop flows return a QR code for in-page display, while mobile flows return an Alipay WAP/app redirect URL.
Direct integration with Alipay Open Platform. Mobile flows return an Alipay WAP/app redirect URL. Desktop flows prefer Face-to-Face Precreate QR payloads; if the merchant has not enabled that product, the provider falls back to Computer Website Pay and also returns the cashier URL so the frontend can render a QR code or open the hosted checkout page directly.
| Parameter | Description | Required |
|-----------|-------------|----------|
@@ -229,7 +229,7 @@ User selects amount and payment method
User completes payment
├─ EasyPay → QR code / H5 redirect
├─ Alipay → Desktop QR / mobile Alipay redirect
├─ Alipay → Desktop QR payload (Face-to-Face preferred, Website Pay fallback) / mobile Alipay redirect
├─ WeChat Pay → Desktop Native QR / non-WeChat H5 / in-WeChat JSAPI
└─ Stripe → Payment Element (card/Alipay/WeChat/etc.)

View File

@@ -122,7 +122,7 @@ Sub2API 内置支付系统,支持用户自助充值,无需部署独立的支
### 支付宝官方
直接对接支付宝开放平台。桌面端返回二维码供页面内展示和扫码,移动端返回支付宝手机网站支付跳转链接
直接对接支付宝开放平台。移动端支付宝手机网站支付跳转;桌面端优先使用当面付返回扫码串,若商户未开通当面付则回退到电脑网站支付,并将收银台链接同时返回给前端用于渲染二维码或直接打开支付页
| 参数 | 说明 | 必填 |
|------|------|------|
@@ -229,7 +229,7 @@ Sub2API 内置支付系统,支持用户自助充值,无需部署独立的支
用户完成支付
├─ EasyPay → 扫码 / H5 跳转
├─ 支付宝官方 → 桌面二维码 / 移动端支付宝跳转
├─ 支付宝官方 → 桌面扫码单(当面付优先,电脑网站支付回退)/ 移动端支付宝跳转
├─ 微信官方 → 桌面 Native 扫码 / 非微信 H5 / 微信内 JSAPI
└─ Stripe → Payment Element银行卡/支付宝/微信等)

View File

@@ -1,23 +1,69 @@
<script setup lang="ts">
import { ref, useTemplateRef, nextTick } from 'vue'
import { onBeforeUnmount, onMounted, ref, useTemplateRef, nextTick } from 'vue'
defineProps<{
const props = withDefaults(defineProps<{
content?: string
}>()
trigger?: 'hover' | 'click'
widthClass?: string
}>(), {
trigger: 'hover',
widthClass: 'w-64',
})
const show = ref(false)
const triggerRef = useTemplateRef<HTMLElement>('trigger')
const tooltipRef = useTemplateRef<HTMLElement>('tooltip')
const tooltipStyle = ref({ top: '0px', left: '0px' })
function onEnter() {
function openTooltip() {
show.value = true
nextTick(updatePosition)
}
function onLeave() {
function closeTooltip() {
show.value = false
}
function onEnter() {
if (props.trigger !== 'hover') return
openTooltip()
}
function onLeave() {
if (props.trigger !== 'hover') return
closeTooltip()
}
function onClick(event: MouseEvent) {
if (props.trigger !== 'click') return
event.stopPropagation()
if (show.value) {
closeTooltip()
return
}
openTooltip()
}
function onDocumentClick(event: MouseEvent) {
if (props.trigger !== 'click' || !show.value) return
const target = event.target as Node | null
if (!target) return
if (triggerRef.value?.contains(target) || tooltipRef.value?.contains(target)) return
closeTooltip()
}
function onDocumentKeydown(event: KeyboardEvent) {
if (props.trigger !== 'click') return
if (event.key === 'Escape') {
closeTooltip()
}
}
function onViewportChange() {
if (!show.value) return
updatePosition()
}
function updatePosition() {
const el = triggerRef.value
if (!el) return
@@ -27,6 +73,20 @@ function updatePosition() {
left: `${rect.left + rect.width / 2 + window.scrollX}px`,
}
}
onMounted(() => {
document.addEventListener('click', onDocumentClick, true)
document.addEventListener('keydown', onDocumentKeydown)
window.addEventListener('resize', onViewportChange)
window.addEventListener('scroll', onViewportChange, true)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick, true)
document.removeEventListener('keydown', onDocumentKeydown)
window.removeEventListener('resize', onViewportChange)
window.removeEventListener('scroll', onViewportChange, true)
})
</script>
<template>
@@ -35,6 +95,7 @@ function updatePosition() {
class="group relative ml-1 inline-flex items-center align-middle"
@mouseenter="onEnter"
@mouseleave="onLeave"
@click="onClick"
>
<!-- Trigger Icon -->
<slot name="trigger">
@@ -56,10 +117,26 @@ function updatePosition() {
<!-- Teleport to body to escape modal overflow clipping -->
<Teleport to="body">
<div
ref="tooltip"
v-show="show"
class="fixed z-[99999] w-64 -translate-x-1/2 -translate-y-full rounded-lg bg-gray-900 p-3 text-xs leading-relaxed text-white shadow-xl ring-1 ring-white/10 dark:bg-gray-800"
role="tooltip"
:class="[
'fixed z-[99999] -translate-x-1/2 -translate-y-full rounded-lg bg-gray-900 p-3 text-xs leading-relaxed text-white shadow-xl ring-1 ring-white/10 dark:bg-gray-800',
props.widthClass,
]"
:style="{ top: `calc(${tooltipStyle.top} - 8px)`, left: tooltipStyle.left }"
>
<button
v-if="props.trigger === 'click'"
type="button"
class="absolute right-1.5 top-1.5 rounded p-1 text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
aria-label="Close"
@click.stop="closeTooltip"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<slot>{{ content }}</slot>
<div class="absolute -bottom-1 left-1/2 h-2 w-2 -translate-x-1/2 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>

View File

@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
function getTooltipElement(): HTMLDivElement {
const tooltip = document.body.querySelector('[role="tooltip"]')
if (!(tooltip instanceof HTMLDivElement)) {
throw new Error('tooltip element not found')
}
return tooltip
}
describe('HelpTooltip', () => {
afterEach(() => {
document.body.innerHTML = ''
})
it('keeps the existing hover interaction by default', async () => {
const wrapper = mount(HelpTooltip, {
attachTo: document.body,
props: {
content: 'hover details',
},
})
const trigger = wrapper.get('.group')
const tooltip = getTooltipElement()
expect(tooltip.style.display).toBe('none')
await trigger.trigger('mouseenter')
await nextTick()
expect(tooltip.style.display).not.toBe('none')
await trigger.trigger('mouseleave')
await nextTick()
expect(tooltip.style.display).toBe('none')
wrapper.unmount()
})
it('supports click-to-toggle details and closes on outside click', async () => {
const wrapper = mount(HelpTooltip, {
attachTo: document.body,
props: {
content: 'click details',
trigger: 'click',
},
})
const trigger = wrapper.get('.group')
const tooltip = getTooltipElement()
expect(tooltip.style.display).toBe('none')
await trigger.trigger('click')
await nextTick()
expect(tooltip.style.display).not.toBe('none')
expect(tooltip.textContent).toContain('click details')
const closeButton = tooltip.querySelector('button[aria-label="Close"]')
if (!(closeButton instanceof HTMLButtonElement)) {
throw new Error('close button not found')
}
closeButton.click()
await nextTick()
expect(tooltip.style.display).toBe('none')
await trigger.trigger('click')
await nextTick()
expect(tooltip.style.display).not.toBe('none')
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
expect(tooltip.style.display).toBe('none')
wrapper.unmount()
})
})

View File

@@ -73,9 +73,42 @@
<!-- Config fields -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-700">
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.payment.providerConfig') }}
</h4>
<div class="mb-3 flex items-center gap-2">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.payment.providerConfig') }}
</h4>
<HelpTooltip v-if="paymentGuide" trigger="click" width-class="w-80">
<template #trigger>
<button
type="button"
class="inline-flex h-5 w-5 items-center justify-center rounded-full border border-gray-300 text-[11px] font-semibold text-gray-400 transition-colors hover:border-primary-500 hover:text-primary-600 dark:border-dark-500 dark:text-gray-500 dark:hover:border-primary-400 dark:hover:text-primary-400"
:aria-label="t('admin.settings.payment.paymentGuideTrigger')"
:title="t('admin.settings.payment.paymentGuideTrigger')"
>
?
</button>
</template>
<div class="space-y-3">
<p class="font-medium text-white">{{ paymentGuide.summary }}</p>
<div
v-for="item in paymentGuide.items"
:key="item.title"
class="space-y-1.5 border-t border-white/10 pt-2 first:border-t-0 first:pt-0"
>
<p class="font-medium text-white">{{ item.title }}</p>
<p><span class="text-gray-300">{{ t('admin.settings.payment.guideOpenLabel') }}</span>{{ item.open }}</p>
<p><span class="text-gray-300">{{ t('admin.settings.payment.guideCallLabel') }}</span>{{ item.call }}</p>
<p><span class="text-gray-300">{{ t('admin.settings.payment.guideFallbackLabel') }}</span>{{ item.fallback }}</p>
</div>
<p v-if="paymentGuide.note" class="border-t border-white/10 pt-2 text-[11px] text-gray-300">
{{ paymentGuide.note }}
</p>
</div>
</HelpTooltip>
</div>
<p v-if="paymentGuide" class="mb-3 text-xs text-gray-500 dark:text-gray-400">
{{ paymentGuide.summary }}
</p>
<div class="space-y-3">
<div v-for="field in resolvedFields" :key="field.key">
<label class="input-label">
@@ -220,6 +253,7 @@
import { reactive, computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
import Select from '@/components/common/Select.vue'
import type { SelectOption } from '@/components/common/Select.vue'
import ToggleSwitch from './ToggleSwitch.vue'
@@ -263,6 +297,19 @@ const emit = defineEmits<{
const { t } = useI18n()
interface PaymentGuideItem {
title: string
open: string
call: string
fallback: string
}
interface PaymentGuide {
summary: string
items: PaymentGuideItem[]
note?: string
}
// --- Form state ---
const form = reactive({
name: '',
@@ -314,6 +361,63 @@ const resolvedFields = computed(() => {
}))
})
const paymentGuide = computed<PaymentGuide | null>(() => {
if (form.provider_key === 'alipay') {
return {
summary: t('admin.settings.payment.alipayGuideSummary'),
items: [
{
title: t('admin.settings.payment.alipayGuideFaceToFaceTitle'),
open: t('admin.settings.payment.alipayGuideFaceToFaceOpen'),
call: t('admin.settings.payment.alipayGuideFaceToFaceCall'),
fallback: t('admin.settings.payment.alipayGuideFaceToFaceFallback'),
},
{
title: t('admin.settings.payment.alipayGuidePagePayTitle'),
open: t('admin.settings.payment.alipayGuidePagePayOpen'),
call: t('admin.settings.payment.alipayGuidePagePayCall'),
fallback: t('admin.settings.payment.alipayGuidePagePayFallback'),
},
{
title: t('admin.settings.payment.alipayGuideWapTitle'),
open: t('admin.settings.payment.alipayGuideWapOpen'),
call: t('admin.settings.payment.alipayGuideWapCall'),
fallback: t('admin.settings.payment.alipayGuideWapFallback'),
},
],
}
}
if (form.provider_key === 'wxpay') {
return {
summary: t('admin.settings.payment.wxpayGuideSummary'),
note: t('admin.settings.payment.wxpayGuideNote'),
items: [
{
title: t('admin.settings.payment.wxpayGuideNativeTitle'),
open: t('admin.settings.payment.wxpayGuideNativeOpen'),
call: t('admin.settings.payment.wxpayGuideNativeCall'),
fallback: t('admin.settings.payment.wxpayGuideNativeFallback'),
},
{
title: t('admin.settings.payment.wxpayGuideJsapiTitle'),
open: t('admin.settings.payment.wxpayGuideJsapiOpen'),
call: t('admin.settings.payment.wxpayGuideJsapiCall'),
fallback: t('admin.settings.payment.wxpayGuideJsapiFallback'),
},
{
title: t('admin.settings.payment.wxpayGuideH5Title'),
open: t('admin.settings.payment.wxpayGuideH5Open'),
call: t('admin.settings.payment.wxpayGuideH5Call'),
fallback: t('admin.settings.payment.wxpayGuideH5Fallback'),
},
],
}
}
return null
})
const limitableTypes = computed(() => {
// Stripe: single "stripe" entry (one set of shared limits)
if (form.provider_key === 'stripe') {

View File

@@ -84,6 +84,9 @@
</div>
</div>
<p v-if="scanHint" class="text-center text-sm text-gray-500 dark:text-gray-400">{{ scanHint }}</p>
<button v-if="payUrl" class="btn btn-secondary text-sm" @click="reopenPopup">
{{ t('payment.qr.openPayWindow') }}
</button>
</div>
</div>
<div class="card p-4 text-center">

View File

@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import PaymentProviderDialog from '@/components/payment/PaymentProviderDialog.vue'
const messages: Record<string, string> = {
'admin.settings.payment.providerConfig': 'Credentials',
'admin.settings.payment.paymentGuideTrigger': 'View payment guide',
'admin.settings.payment.alipayGuideSummary': 'Desktop prefers QR precreate and falls back to cashier; mobile prefers WAP checkout.',
'admin.settings.payment.wxpayGuideSummary': 'Desktop prefers Native QR; mobile routes to JSAPI or H5 based on browser context.',
}
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => messages[key] ?? key,
}),
}))
function mountDialog() {
return mount(PaymentProviderDialog, {
props: {
show: true,
saving: false,
editing: null,
allKeyOptions: [
{ value: 'alipay', label: 'Alipay' },
{ value: 'wxpay', label: 'WeChat Pay' },
{ value: 'stripe', label: 'Stripe' },
],
enabledKeyOptions: [
{ value: 'alipay', label: 'Alipay' },
{ value: 'wxpay', label: 'WeChat Pay' },
],
allPaymentTypes: [
{ value: 'alipay', label: 'Alipay' },
{ value: 'wxpay', label: 'WeChat Pay' },
],
redirectLabel: 'Redirect',
},
global: {
stubs: {
BaseDialog: {
template: '<div><slot /><slot name="footer" /></div>',
},
Select: {
props: ['modelValue', 'options', 'disabled'],
template: '<div />',
},
ToggleSwitch: {
template: '<div />',
},
},
},
})
}
describe('PaymentProviderDialog payment guide', () => {
it('shows no payment guide for providers without a flow guide', () => {
const wrapper = mountDialog()
expect(wrapper.text()).not.toContain(messages['admin.settings.payment.alipayGuideSummary'])
expect(wrapper.text()).not.toContain(messages['admin.settings.payment.wxpayGuideSummary'])
expect(wrapper.find('button[title="View payment guide"]').exists()).toBe(false)
})
it.each([
['alipay', 'admin.settings.payment.alipayGuideSummary'],
['wxpay', 'admin.settings.payment.wxpayGuideSummary'],
])('shows the payment guide summary for %s', async (providerKey, summaryKey) => {
const wrapper = mountDialog()
;(wrapper.vm as unknown as { reset: (key: string) => void }).reset(providerKey)
await nextTick()
expect(wrapper.text()).toContain(messages[summaryKey])
expect(wrapper.find('button[title="View payment guide"]').exists()).toBe(true)
})
})

View File

@@ -96,4 +96,36 @@ describe('PaymentStatusPanel', () => {
expect(wrapper.text()).toContain('payment.result.success')
expect(wrapper.emitted('success')).toHaveLength(1)
})
it('shows reopen button in QR mode when payUrl is also available', async () => {
const openSpy = vi.spyOn(window, 'open').mockReturnValue({ closed: false } as Window)
const wrapper = mount(PaymentStatusPanel, {
props: {
orderId: 42,
qrCode: 'https://pay.example.com/qr/42',
payUrl: 'https://pay.example.com/session/42',
expiresAt: '2099-01-01T12:30:00Z',
paymentType: 'alipay',
orderType: 'balance',
},
global: {
stubs: {
Icon: true,
},
},
})
await flushPromises()
expect(wrapper.text()).toContain('payment.qr.openPayWindow')
await wrapper.get('button.btn.btn-secondary.text-sm').trigger('click')
expect(openSpy).toHaveBeenCalledWith(
'https://pay.example.com/session/42',
'paymentPopup',
expect.any(String),
)
openSpy.mockRestore()
})
})

View File

@@ -190,12 +190,14 @@ describe('buildCreateOrderPayload', () => {
paymentType: 'alipay_direct',
orderType: 'balance',
origin: 'https://app.example.com/',
isMobile: true,
isWechatBrowser: false,
})).toEqual({
amount: 88,
payment_type: 'alipay',
order_type: 'balance',
return_url: 'https://app.example.com/payment/result',
is_mobile: true,
payment_source: 'hosted_redirect',
})
})
@@ -207,6 +209,7 @@ describe('buildCreateOrderPayload', () => {
orderType: 'subscription',
planId: 7,
origin: 'https://app.example.com',
isMobile: false,
isWechatBrowser: true,
})).toEqual({
amount: 128,
@@ -214,6 +217,7 @@ describe('buildCreateOrderPayload', () => {
order_type: 'subscription',
plan_id: 7,
return_url: 'https://app.example.com/payment/result',
is_mobile: false,
payment_source: 'wechat_in_app_resume',
})
})

View File

@@ -12,9 +12,9 @@ describe('PROVIDER_CONFIG_FIELDS.wxpay', () => {
expect(findField('certSerial')?.optional).toBeFalsy()
})
it('exposes optional mp and H5 metadata fields for WeChat-specific flows', () => {
expect(findField('mpAppId')?.optional).toBe(true)
expect(findField('h5AppName')?.optional).toBe(true)
expect(findField('h5AppUrl')?.optional).toBe(true)
it('only keeps the simplified visible credential set in the admin form', () => {
expect(findField('mpAppId')).toBeUndefined()
expect(findField('h5AppName')).toBeUndefined()
expect(findField('h5AppUrl')).toBeUndefined()
})
})

View File

@@ -68,6 +68,7 @@ export interface BuildCreateOrderPayloadInput {
orderType: OrderType
planId?: number
origin?: string
isMobile: boolean
isWechatBrowser: boolean
}
@@ -106,6 +107,7 @@ export function buildCreateOrderPayload(input: BuildCreateOrderPayloadInput): Cr
amount: input.amount,
payment_type: visibleMethod,
order_type: input.orderType,
is_mobile: input.isMobile,
payment_source: visibleMethod === 'wxpay' && input.isWechatBrowser
? 'wechat_in_app_resume'
: 'hosted_redirect',

View File

@@ -96,15 +96,12 @@ export const PROVIDER_CONFIG_FIELDS: Record<string, ConfigFieldDef[]> = {
],
wxpay: [
{ key: 'appId', label: 'App ID', sensitive: false },
{ key: 'mpAppId', label: '', sensitive: false, optional: true },
{ key: 'mchId', label: '', sensitive: false },
{ key: 'privateKey', label: '', sensitive: true },
{ key: 'apiV3Key', label: '', sensitive: true },
{ key: 'certSerial', label: '', sensitive: false },
{ key: 'publicKey', label: '', sensitive: true },
{ key: 'publicKeyId', label: '', sensitive: false },
{ key: 'h5AppName', label: '', sensitive: false, optional: true },
{ key: 'h5AppUrl', label: '', sensitive: false, optional: true },
],
stripe: [
{ key: 'secretKey', label: '', sensitive: true },

View File

@@ -4741,6 +4741,8 @@ export default {
field_certSerial: 'Certificate Serial',
field_h5AppName: 'H5 App Name',
field_h5AppUrl: 'H5 App URL',
wxpayConfigHint: 'WeChat Pay usually only needs App ID. Fill MP App ID, H5 App Name, and H5 App URL only when your Official Account or H5 flow specifically requires them.',
wxpayAdvancedOptions: 'WeChat Pay Advanced Options',
field_secretKey: 'Secret Key',
field_publishableKey: 'Publishable Key',
field_webhookSecret: 'Webhook Secret',
@@ -4771,6 +4773,37 @@ export default {
providerKey: 'Provider Type',
selectProviderKey: 'Select Provider Type',
providerConfig: 'Credentials',
paymentGuideTrigger: 'View payment guide',
guideOpenLabel: 'Enable: ',
guideCallLabel: 'Call: ',
guideFallbackLabel: 'Fallback: ',
alipayGuideSummary: 'Desktop prefers QR precreate and falls back to cashier; mobile prefers WAP checkout.',
alipayGuideFaceToFaceTitle: 'Face-to-face / QR Payment',
alipayGuideFaceToFaceOpen: 'Enable face-to-face or QR payment capability.',
alipayGuideFaceToFaceCall: 'Desktop orders call alipay.trade.precreate first and render the QR code directly.',
alipayGuideFaceToFaceFallback: 'If unavailable or failed, the flow falls back to website checkout automatically.',
alipayGuidePagePayTitle: 'Website Payment',
alipayGuidePagePayOpen: 'Enable website payment.',
alipayGuidePagePayCall: 'When face-to-face is unavailable on desktop, the flow calls alipay.trade.page.pay and still renders the returned link as a QR code.',
alipayGuidePagePayFallback: 'The cashier link stays available so users can reopen the checkout page manually.',
alipayGuideWapTitle: 'WAP Payment',
alipayGuideWapOpen: 'Enable mobile website payment.',
alipayGuideWapCall: 'Mobile orders call alipay.trade.wap.pay first and jump to Alipay checkout.',
alipayGuideWapFallback: 'If mobile payment is unavailable or fails, the frontend switches to QR payment and shows a notice.',
wxpayGuideSummary: 'Desktop prefers Native QR; mobile routes to JSAPI or H5 based on browser context.',
wxpayGuideNote: 'The current form defaults to one shared App ID, which fits the common single-subject web, mobile, and Official Account setup.',
wxpayGuideNativeTitle: 'Native / QR Payment',
wxpayGuideNativeOpen: 'Enable Native or QR payment capability.',
wxpayGuideNativeCall: 'Desktop orders use Native by default and the frontend renders the QR payload.',
wxpayGuideNativeFallback: 'Mobile flows also fall back here when JSAPI or H5 cannot be used.',
wxpayGuideJsapiTitle: 'JSAPI / Official Account',
wxpayGuideJsapiOpen: 'Enable Official Account payment and ensure the browser is inside WeChat with an available OpenID.',
wxpayGuideJsapiCall: 'Inside WeChat, the app calls JSAPI after authorization and launches WeChat Pay directly.',
wxpayGuideJsapiFallback: 'If configuration is missing, the bridge is unavailable, or launch fails, the flow falls back to QR payment.',
wxpayGuideH5Title: 'H5 Payment',
wxpayGuideH5Open: 'Enable H5 payment.',
wxpayGuideH5Call: 'On mobile browsers outside WeChat, the app calls H5 payment when a client IP is available.',
wxpayGuideH5Fallback: 'If H5 is unavailable or order creation fails, the flow falls back to QR payment.',
noProviders: 'No provider instances configured',
supportedTypes: 'Supported Payment Types',
supportedTypesHint: 'Comma-separated, e.g. alipay,wxpay',
@@ -5627,6 +5660,7 @@ export default {
wechatOpenInWeChatHint: 'Open the current page inside WeChat, or switch to desktop WeChat QR payment.',
wechatScanOnDesktopHint: 'On desktop, use WeChat Scan to pay; on mobile, reopen the current page inside WeChat.',
wechatSwitchBrowserHint: 'Switch to desktop WeChat QR payment, or reopen this page in an external browser and retry.',
mobilePaymentFallbackToQr: 'This merchant has not enabled mobile payment. The flow has been switched to QR payment automatically.',
alipayDesktopUnavailable: 'The desktop Alipay flow could not generate a QR code.',
alipayDesktopQrHint: 'Desktop Alipay should render a QR code. Refresh and retry, or make sure the payment page was not blocked.',
alipayMobileUnavailable: 'This page could not hand off to Alipay.',

View File

@@ -4905,6 +4905,8 @@ export default {
field_certSerial: '证书序列号',
field_h5AppName: 'H5 应用名称',
field_h5AppUrl: 'H5 应用地址',
wxpayConfigHint: '微信支付通常只需要填写 App ID。公众号 App ID、H5 应用名称、H5 应用地址仅在公众号支付或 H5 场景有特殊要求时再填写。',
wxpayAdvancedOptions: '微信支付高级可选项',
field_secretKey: '密钥',
field_publishableKey: '公开密钥',
field_webhookSecret: 'Webhook 密钥',
@@ -4935,6 +4937,37 @@ export default {
providerKey: '服务商类型',
selectProviderKey: '选择服务商类型',
providerConfig: '凭证配置',
paymentGuideTrigger: '查看支付方式说明',
guideOpenLabel: '开通:',
guideCallLabel: '调用:',
guideFallbackLabel: '降级:',
alipayGuideSummary: '桌面优先扫码单,失败再走收银台;移动优先手机网站支付。',
alipayGuideFaceToFaceTitle: '当面付 / 扫码支付',
alipayGuideFaceToFaceOpen: '需开通当面付或扫码支付能力。',
alipayGuideFaceToFaceCall: '桌面端下单时优先调用 alipay.trade.precreate前台直接渲染二维码。',
alipayGuideFaceToFaceFallback: '接口不可用或返回失败时,自动降级到电脑网站支付。',
alipayGuidePagePayTitle: '电脑网站支付',
alipayGuidePagePayOpen: '需开通电脑网站支付。',
alipayGuidePagePayCall: '桌面端当面付不可用时调用 alipay.trade.page.pay并继续把返回链接渲染成二维码。',
alipayGuidePagePayFallback: '同时保留打开收银台入口,用户可手动重新拉起支付页。',
alipayGuideWapTitle: '手机网站支付',
alipayGuideWapOpen: '需开通手机网站支付。',
alipayGuideWapCall: '移动端优先调用 alipay.trade.wap.pay跳转支付宝收银台。',
alipayGuideWapFallback: '未开通或返回异常时,前端自动改走扫码支付并提示未开通移动支付。',
wxpayGuideSummary: '桌面优先 Native 扫码,移动端按浏览器环境走 JSAPI 或 H5。',
wxpayGuideNote: '当前表单默认共用一个 App ID适合同主体下统一配置网页、移动和公众号场景。',
wxpayGuideNativeTitle: 'Native / 扫码支付',
wxpayGuideNativeOpen: '需开通 Native 或扫码支付能力。',
wxpayGuideNativeCall: '桌面端默认调用 Native下发二维码内容给前台渲染。',
wxpayGuideNativeFallback: '移动端无法走 JSAPI 或 H5 时,也会自动回退到这里。',
wxpayGuideJsapiTitle: 'JSAPI / 公众号支付',
wxpayGuideJsapiOpen: '需开通公众号支付,并保证当前浏览器在微信内且能拿到 OpenID。',
wxpayGuideJsapiCall: '微信内浏览器完成授权后调用 JSAPI直接拉起微信支付。',
wxpayGuideJsapiFallback: '未配置、Bridge 不可用或拉起失败时,自动改走扫码支付。',
wxpayGuideH5Title: 'H5 支付',
wxpayGuideH5Open: '需开通 H5 支付。',
wxpayGuideH5Call: '移动端非微信浏览器且有客户端 IP 时调用 H5 支付,跳转微信收银台。',
wxpayGuideH5Fallback: '未开通 H5 或下单失败时,自动改走扫码支付。',
noProviders: '暂无服务商实例',
supportedTypes: '支持的支付方式',
supportedTypesHint: '逗号分隔,如 alipay,wxpay',
@@ -5815,6 +5848,7 @@ export default {
wechatOpenInWeChatHint: '请复制当前页面链接到微信内打开,或直接改用电脑端微信扫码支付。',
wechatScanOnDesktopHint: '电脑端请直接使用微信扫一扫完成支付;移动端请在微信内打开当前页面。',
wechatSwitchBrowserHint: '请改用电脑端微信扫码,或在外部浏览器重新打开本页后再试。',
mobilePaymentFallbackToQr: '当前商户未开通移动支付,已自动切换为扫码支付。',
alipayDesktopUnavailable: '当前支付宝桌面支付未成功生成二维码。',
alipayDesktopQrHint: '电脑端支付宝应展示扫码单,请刷新后重试,或确认浏览器未拦截当前支付页。',
alipayMobileUnavailable: '当前页面未成功跳转到支付宝。',

View File

@@ -4166,6 +4166,8 @@
}}</label>
<ImageUpload
v-model="form.payment_help_image_url"
:upload-label="t('admin.settings.site.uploadImage')"
:remove-label="t('admin.settings.site.remove')"
:placeholder="
t('admin.settings.payment.helpImagePlaceholder')
"

View File

@@ -155,6 +155,8 @@ vi.mock("vue-i18n", async () => {
"admin.settings.payment.findProvider": "查看支持的支付方式",
"admin.settings.openaiExperimentalScheduler.title": "OpenAI 实验调度策略",
"admin.settings.openaiExperimentalScheduler.description": "默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。",
"admin.settings.site.uploadImage": "上传图片",
"admin.settings.site.remove": "移除",
};
return {
...actual,
@@ -240,6 +242,37 @@ const SelectStub = defineComponent({
},
});
const ImageUploadStub = defineComponent({
props: {
modelValue: {
type: String,
default: "",
},
uploadLabel: {
type: String,
default: "",
},
removeLabel: {
type: String,
default: "",
},
placeholder: {
type: String,
default: "",
},
},
setup(props) {
return () =>
h("div", {
class: "image-upload-stub",
"data-model-value": props.modelValue,
"data-upload-label": props.uploadLabel,
"data-remove-label": props.removeLabel,
"data-placeholder": props.placeholder,
});
},
});
const baseSettingsResponse = {
registration_enabled: true,
email_verify_enabled: false,
@@ -375,7 +408,7 @@ function mountView() {
GroupBadge: true,
GroupOptionItem: true,
ProxySelector: true,
ImageUpload: true,
ImageUpload: ImageUploadStub,
BackupSettings: true,
},
},
@@ -582,7 +615,7 @@ describe("admin SettingsView payment visible method controls", () => {
GroupBadge: true,
GroupOptionItem: true,
ProxySelector: true,
ImageUpload: true,
ImageUpload: ImageUploadStub,
BackupSettings: true,
},
},
@@ -608,6 +641,24 @@ describe("admin SettingsView payment visible method controls", () => {
);
expect(wrapper.text()).not.toContain("OpenAI 高级调度器");
});
it("passes translated upload and remove labels to the payment help image uploader", async () => {
const wrapper = mountView();
await flushPromises();
await openPaymentTab(wrapper);
const imageUploads = wrapper.findAll(".image-upload-stub");
expect(imageUploads.length).toBeGreaterThan(0);
const paymentHelpImageUpload = imageUploads.find(
(node) => node.attributes("data-placeholder") === "admin.settings.payment.helpImagePlaceholder",
);
expect(paymentHelpImageUpload).toBeDefined();
expect(paymentHelpImageUpload?.attributes("data-upload-label")).toBe("上传图片");
expect(paymentHelpImageUpload?.attributes("data-remove-label")).toBe("移除");
});
});
describe("admin SettingsView wechat connect controls", () => {

View File

@@ -311,6 +311,7 @@ interface CreateOrderOptions {
wechatResumeToken?: string
paymentType?: string
isResume?: boolean
mobileQrFallbackAttempted?: boolean
}
interface WeixinJSBridgeLike {
@@ -666,14 +667,15 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
submitting.value = true
errorMessage.value = ''
errorHintMessage.value = ''
const requestType = normalizeVisibleMethod(options.paymentType || selectedMethod.value) || options.paymentType || selectedMethod.value
try {
const requestType = normalizeVisibleMethod(options.paymentType || selectedMethod.value) || options.paymentType || selectedMethod.value
const payload = buildCreateOrderPayload({
amount: orderAmount,
paymentType: requestType,
orderType,
planId,
origin: typeof window !== 'undefined' ? window.location.origin : '',
isMobile: isMobileDevice(),
isWechatBrowser: typeof window !== 'undefined' && /MicroMessenger/i.test(window.navigator.userAgent),
})
if (options.openid) {
@@ -747,8 +749,20 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
appStore.showInfo(t('payment.qr.cancelled'))
resetPayment()
} else if (errMsg && !errMsg.includes('ok')) {
applyScenarioError({ reason: 'WECHAT_JSAPI_FAILED', message: errMsg }, visibleMethod)
resetPayment()
const fallbackApplied = await attemptMobileQrFallback(
{ reason: 'WECHAT_JSAPI_FAILED', message: errMsg },
{
orderAmount,
orderType,
planId,
paymentType: visibleMethod,
attempted: options.mobileQrFallbackAttempted === true,
},
)
if (!fallbackApplied) {
applyScenarioError({ reason: 'WECHAT_JSAPI_FAILED', message: errMsg }, visibleMethod)
}
} else {
const resultState = { ...decision.paymentState }
resetPayment()
@@ -756,7 +770,16 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
}
} catch (err: unknown) {
resetPayment()
throw err
const fallbackApplied = await attemptMobileQrFallback(err, {
orderAmount,
orderType,
planId,
paymentType: visibleMethod,
attempted: options.mobileQrFallbackAttempted === true,
})
if (!fallbackApplied) {
throw err
}
}
return
}
@@ -776,6 +799,14 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
} else if (apiErr.reason === 'CANCEL_RATE_LIMITED') {
errorMessage.value = t('payment.errors.cancelRateLimited')
errorHintMessage.value = ''
} else if (await attemptMobileQrFallback(err, {
orderAmount,
orderType,
planId,
paymentType: requestType,
attempted: options.mobileQrFallbackAttempted === true,
})) {
return
} else {
const handled = applyScenarioError(
err,
@@ -795,6 +826,101 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
}
}
interface MobileQrFallbackContext {
orderAmount: number
orderType: OrderType
planId?: number
paymentType: string
attempted: boolean
}
function shouldFallbackToDesktopQr(err: unknown, paymentMethod: string, attempted: boolean): boolean {
if (attempted || !isMobileDevice()) {
return false
}
const normalizedMethod = normalizeVisibleMethod(paymentMethod) || paymentMethod
const reason = typeof err === 'object' && err && 'reason' in err && typeof err.reason === 'string'
? err.reason
: ''
const message = err instanceof Error
? err.message
: (typeof err === 'object' && err && 'message' in err && typeof err.message === 'string'
? err.message
: '')
const normalizedMessage = message.toLowerCase()
if (normalizedMethod === 'wxpay') {
return reason === 'WECHAT_H5_NOT_AUTHORIZED'
|| reason === 'WECHAT_PAYMENT_MP_NOT_CONFIGURED'
|| reason === 'WECHAT_JSAPI_FAILED'
|| reason === 'PAYMENT_GATEWAY_ERROR'
|| reason === 'UNHANDLED_PAYMENT_SCENARIO'
|| normalizedMessage.includes('weixinjsbridge is unavailable')
|| normalizedMessage.includes('wechat_jsapi_unavailable')
}
if (normalizedMethod === 'alipay') {
return reason === 'PAYMENT_GATEWAY_ERROR' || reason === 'UNHANDLED_PAYMENT_SCENARIO'
}
return false
}
async function attemptMobileQrFallback(err: unknown, context: MobileQrFallbackContext): Promise<boolean> {
if (!shouldFallbackToDesktopQr(err, context.paymentType, context.attempted)) {
return false
}
try {
const visibleMethod = normalizeVisibleMethod(context.paymentType) || context.paymentType
const payload = buildCreateOrderPayload({
amount: context.orderAmount,
paymentType: visibleMethod,
orderType: context.orderType,
planId: context.planId,
origin: typeof window !== 'undefined' ? window.location.origin : '',
isMobile: false,
isWechatBrowser: false,
})
const result = await paymentStore.createOrder(payload) as CreateOrderResult & { resume_token?: string }
const stripeMethod = visibleMethod === 'wxpay' ? 'wechat_pay' : 'alipay'
const stripeRouteUrl = result.client_secret
? router.resolve({
path: '/payment/stripe',
query: {
order_id: String(result.order_id),
client_secret: result.client_secret,
method: stripeMethod,
resume_token: result.resume_token || undefined,
},
}).href
: ''
const decision = decidePaymentLaunch(result, {
visibleMethod,
orderType: context.orderType,
isMobile: false,
isWechatBrowser: false,
stripePopupUrl: stripeRouteUrl,
stripeRouteUrl,
})
if (decision.kind !== 'qr_waiting' || !decision.paymentState.qrCode) {
return false
}
errorMessage.value = ''
errorHintMessage.value = ''
paymentState.value = decision.paymentState
paymentPhase.value = 'paying'
persistRecoverySnapshot(decision.recovery)
appStore.showWarning(t('payment.errors.mobilePaymentFallbackToQr'))
return true
} catch {
return false
}
}
function applyScenarioError(err: unknown, paymentMethod: string): boolean {
const descriptor = describePaymentScenarioError(err, {
paymentMethod,

View File

@@ -16,6 +16,7 @@ const refreshUser = vi.hoisted(() => vi.fn())
const fetchActiveSubscriptions = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
const showError = vi.hoisted(() => vi.fn())
const showInfo = vi.hoisted(() => vi.fn())
const showWarning = vi.hoisted(() => vi.fn())
const getCheckoutInfo = vi.hoisted(() => vi.fn())
const bridgeInvoke = vi.hoisted(() => vi.fn())
@@ -69,6 +70,7 @@ vi.mock('@/stores', () => ({
useAppStore: () => ({
showError,
showInfo,
showWarning,
}),
}))
@@ -193,6 +195,7 @@ describe('PaymentView WeChat JSAPI flow', () => {
fetchActiveSubscriptions.mockReset().mockResolvedValue(undefined)
showError.mockReset()
showInfo.mockReset()
showWarning.mockReset()
getCheckoutInfo.mockReset().mockResolvedValue(checkoutInfoFixture())
bridgeInvoke.mockReset()
window.localStorage.clear()
@@ -364,13 +367,24 @@ describe('PaymentView WeChat JSAPI flow', () => {
})
})
it('shows explicit H5 authorization guidance instead of failing silently', async () => {
it('falls back to QR flow when mobile WeChat payment is unavailable', async () => {
routeState.query = {
wechat_resume: '1',
wechat_resume_token: 'resume-token-h5',
payment_type: 'wxpay_direct',
}
createOrder.mockRejectedValueOnce({ reason: 'WECHAT_H5_NOT_AUTHORIZED' })
createOrder
.mockRejectedValueOnce({ reason: 'WECHAT_H5_NOT_AUTHORIZED' })
.mockResolvedValueOnce({
order_id: 778,
amount: 88,
pay_amount: 88,
fee_rate: 0,
expires_at: '2099-01-01T00:10:00.000Z',
payment_type: 'wxpay',
qr_code: 'weixin://wxpay/bizpayurl?pr=fallback-native',
out_trade_no: 'sub2_qr_778',
})
shallowMount(PaymentView, {
global: {
@@ -383,8 +397,18 @@ describe('PaymentView WeChat JSAPI flow', () => {
await flushPromises()
await flushPromises()
expect(showError).toHaveBeenCalledWith(
'payment.errors.wechatH5NotAuthorized payment.errors.wechatOpenInWeChatHint',
)
expect(createOrder).toHaveBeenNthCalledWith(1, expect.objectContaining({
payment_type: 'wxpay',
is_mobile: true,
wechat_resume_token: 'resume-token-h5',
}))
expect(createOrder).toHaveBeenNthCalledWith(2, expect.objectContaining({
payment_type: 'wxpay',
is_mobile: false,
payment_source: 'hosted_redirect',
}))
expect(showWarning).toHaveBeenCalledWith('payment.errors.mobilePaymentFallbackToQr')
expect(showError).not.toHaveBeenCalled()
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toContain('weixin://wxpay/bizpayurl?pr=fallback-native')
})
})