diff --git a/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts b/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts
new file mode 100644
index 00000000..637d805f
--- /dev/null
+++ b/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts
@@ -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
= {
+ '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: '
',
+ },
+ Select: {
+ props: ['modelValue', 'options', 'disabled'],
+ template: '',
+ },
+ ToggleSwitch: {
+ template: '',
+ },
+ },
+ },
+ })
+}
+
+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)
+ })
+})
diff --git a/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts b/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts
index d7017e1f..ea2b6377 100644
--- a/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts
+++ b/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts
@@ -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()
+ })
})
diff --git a/frontend/src/components/payment/__tests__/paymentFlow.spec.ts b/frontend/src/components/payment/__tests__/paymentFlow.spec.ts
index 48c77dfb..72dc61f1 100644
--- a/frontend/src/components/payment/__tests__/paymentFlow.spec.ts
+++ b/frontend/src/components/payment/__tests__/paymentFlow.spec.ts
@@ -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',
})
})
diff --git a/frontend/src/components/payment/__tests__/providerConfig.spec.ts b/frontend/src/components/payment/__tests__/providerConfig.spec.ts
index 6a4c9c26..ec63726f 100644
--- a/frontend/src/components/payment/__tests__/providerConfig.spec.ts
+++ b/frontend/src/components/payment/__tests__/providerConfig.spec.ts
@@ -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()
})
})
diff --git a/frontend/src/components/payment/paymentFlow.ts b/frontend/src/components/payment/paymentFlow.ts
index 05f36ed0..2a5f93dc 100644
--- a/frontend/src/components/payment/paymentFlow.ts
+++ b/frontend/src/components/payment/paymentFlow.ts
@@ -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',
diff --git a/frontend/src/components/payment/providerConfig.ts b/frontend/src/components/payment/providerConfig.ts
index 67ffdec1..f4f5acdc 100644
--- a/frontend/src/components/payment/providerConfig.ts
+++ b/frontend/src/components/payment/providerConfig.ts
@@ -96,15 +96,12 @@ export const PROVIDER_CONFIG_FIELDS: Record = {
],
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 },
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index bbddfa35..1b7ffa81 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -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.',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index e7cda148..beb6841f 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -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: '当前页面未成功跳转到支付宝。',
diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue
index 1144dfb4..f3dd653d 100644
--- a/frontend/src/views/admin/SettingsView.vue
+++ b/frontend/src/views/admin/SettingsView.vue
@@ -4166,6 +4166,8 @@
}}
{
"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", () => {
diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue
index 1040d3f6..7cb4343d 100644
--- a/frontend/src/views/user/PaymentView.vue
+++ b/frontend/src/views/user/PaymentView.vue
@@ -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 {
+ 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,
diff --git a/frontend/src/views/user/__tests__/PaymentView.spec.ts b/frontend/src/views/user/__tests__/PaymentView.spec.ts
index d2683161..b4cd2cdd 100644
--- a/frontend/src/views/user/__tests__/PaymentView.spec.ts
+++ b/frontend/src/views/user/__tests__/PaymentView.spec.ts
@@ -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')
})
})