fix payment qr fallback and admin guidance
This commit is contained in:
@@ -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')
|
||||
"
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user