fix: restore wechat payment oauth and jsapi flow

This commit is contained in:
IanShaw027
2026-04-20 23:34:57 +08:00
parent 6f00efa350
commit 7ef7fd19e7
16 changed files with 1563 additions and 87 deletions

View File

@@ -105,6 +105,50 @@ describe('decidePaymentLaunch', () => {
expect(decision.recovery.paymentMode).toBe('popup')
expect(decision.recovery.resumeToken).toBe('resume-2')
})
it('returns wechat oauth launch when backend requires in-app authorization', () => {
const decision = decidePaymentLaunch(createOrderResult({
result_type: 'oauth_required',
payment_type: 'wxpay',
oauth: {
authorize_url: '/api/v1/auth/oauth/wechat/payment/start?payment_type=wxpay',
appid: 'wx123',
scope: 'snsapi_base',
redirect_url: '/auth/wechat/payment/callback',
},
}), {
visibleMethod: 'wxpay',
orderType: 'balance',
isMobile: true,
})
expect(decision.kind).toBe('wechat_oauth')
expect(decision.oauth?.authorize_url).toContain('/api/v1/auth/oauth/wechat/payment/start')
expect(decision.paymentState.paymentType).toBe('wxpay')
})
it('returns wechat jsapi launch when backend has a jsapi payload ready', () => {
const decision = decidePaymentLaunch(createOrderResult({
result_type: 'jsapi_ready',
payment_type: 'wxpay',
jsapi: {
appId: 'wx123',
timeStamp: '1712345678',
nonceStr: 'nonce-123',
package: 'prepay_id=wx123',
signType: 'RSA',
paySign: 'signed-payload',
},
}), {
visibleMethod: 'wxpay',
orderType: 'subscription',
isMobile: true,
})
expect(decision.kind).toBe('wechat_jsapi')
expect(decision.jsapi?.appId).toBe('wx123')
expect(decision.paymentState.orderType).toBe('subscription')
})
})
describe('buildCreateOrderPayload', () => {

View File

@@ -1,4 +1,11 @@
import type { CreateOrderRequest, CreateOrderResult, MethodLimit, OrderType } from '@/types/payment'
import type {
CreateOrderRequest,
CreateOrderResult,
MethodLimit,
OrderType,
WechatJSAPIPayload,
WechatOAuthInfo,
} from '@/types/payment'
export const PAYMENT_RECOVERY_STORAGE_KEY = 'payment.recovery.current'
@@ -16,6 +23,8 @@ export type PaymentLaunchKind =
| 'redirect_waiting'
| 'stripe_popup'
| 'stripe_route'
| 'wechat_oauth'
| 'wechat_jsapi'
| 'unhandled'
export interface PaymentRecoverySnapshot {
@@ -47,6 +56,8 @@ export interface PaymentLaunchDecision {
paymentState: PaymentRecoverySnapshot
recovery: PaymentRecoverySnapshot
stripeMethod?: StripeVisibleMethod
oauth?: WechatOAuthInfo
jsapi?: WechatJSAPIPayload
}
export interface BuildCreateOrderPayloadInput {
@@ -139,6 +150,15 @@ export function decidePaymentLaunch(
return { kind, paymentState, recovery: paymentState, stripeMethod }
}
if (result.result_type === 'oauth_required' && result.oauth?.authorize_url) {
return { kind: 'wechat_oauth', paymentState: baseState, recovery: baseState, oauth: result.oauth }
}
const jsapiPayload = result.jsapi ?? result.jsapi_payload
if (result.result_type === 'jsapi_ready' && jsapiPayload) {
return { kind: 'wechat_jsapi', paymentState: baseState, recovery: baseState, jsapi: jsapiPayload }
}
if (baseState.qrCode) {
return { kind: 'qr_waiting', paymentState: baseState, recovery: baseState }
}

View File

@@ -92,6 +92,15 @@ const routes: RouteRecordRaw[] = [
title: 'WeChat OAuth Callback'
}
},
{
path: '/auth/wechat/payment/callback',
name: 'WeChatPaymentOAuthCallback',
component: () => import('@/views/auth/WechatPaymentCallbackView.vue'),
meta: {
requiresAuth: false,
title: 'WeChat Payment Callback'
}
},
{
path: '/auth/oidc/callback',
name: 'OIDCOAuthCallback',

View File

@@ -156,6 +156,28 @@ export interface CreateOrderRequest {
plan_id?: number
return_url?: string
payment_source?: string
openid?: string
is_mobile?: boolean
}
export type CreateOrderResultType = 'order_created' | 'oauth_required' | 'jsapi_ready'
export interface WechatOAuthInfo {
authorize_url?: string
appid?: string
openid?: string
scope?: string
state?: string
redirect_url?: string
}
export interface WechatJSAPIPayload {
appId?: string
timeStamp?: string
nonceStr?: string
package?: string
signType?: string
paySign?: string
}
export interface CreateOrderResult {
@@ -167,8 +189,14 @@ export interface CreateOrderResult {
pay_amount: number
fee_rate: number
expires_at: string
result_type?: CreateOrderResultType
payment_type?: string
out_trade_no?: string
payment_mode?: string
resume_token?: string
oauth?: WechatOAuthInfo
jsapi?: WechatJSAPIPayload
jsapi_payload?: WechatJSAPIPayload
}
export interface DashboardStats {

View File

@@ -0,0 +1,155 @@
<template>
<div class="min-h-screen bg-gray-50 px-4 py-10 dark:bg-dark-900">
<div class="mx-auto max-w-2xl">
<div class="card p-6">
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ callbackTitleText }}
</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{{ errorMessage || callbackProcessingText }}
</p>
<div
v-if="!errorMessage"
class="mt-6 flex items-center justify-center py-10"
>
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"
></div>
</div>
<div
v-else
class="mt-6 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-700/50 dark:bg-red-900/20"
>
<p class="text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
<button
class="btn btn-primary mt-4"
type="button"
@click="goBackToPayment"
>
{{ backToPaymentText }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
const { t, locale } = useI18n()
const route = useRoute()
const router = useRouter()
const errorMessage = ref('')
function textWithFallback(key: string, zh: string, en: string): string {
const translated = t(key)
if (translated !== key) return translated
return String(locale.value).toLowerCase().startsWith('zh') ? zh : en
}
const callbackProcessingText = computed(() =>
textWithFallback(
'auth.wechatPayment.callbackProcessing',
'正在恢复微信支付...',
'Resuming WeChat payment...',
))
const callbackTitleText = computed(() =>
textWithFallback(
'auth.wechatPayment.callbackTitle',
'正在恢复微信支付',
'Resuming WeChat payment',
))
const backToPaymentText = computed(() =>
textWithFallback(
'auth.wechatPayment.backToPayment',
'返回支付页',
'Back to payment',
))
function readQueryString(key: string): string {
const value = route.query[key]
if (Array.isArray(value)) {
return typeof value[0] === 'string' ? value[0] : ''
}
return typeof value === 'string' ? value : ''
}
function parseFragmentParams(): URLSearchParams {
const raw = typeof window !== 'undefined' ? window.location.hash : ''
const hash = raw.startsWith('#') ? raw.slice(1) : raw
return new URLSearchParams(hash)
}
function normalizeRedirectPath(path: string | null | undefined): string {
const value = (path || '').trim()
if (!value) return '/purchase'
if (!value.startsWith('/')) return '/purchase'
if (value.startsWith('//') || value.includes('://')) return '/purchase'
if (value === '/payment') return '/purchase'
if (value.startsWith('/payment?')) return '/purchase' + value.slice('/payment'.length)
return value
}
function goBackToPayment() {
void router.replace('/purchase')
}
onMounted(async () => {
const fragment = parseFragmentParams()
const readParam = (key: string) => fragment.get(key) || readQueryString(key)
const error = readParam('error') || readParam('err_msg') || readParam('errmsg')
const errorDescription = readParam('error_description') || readParam('message')
if (error) {
errorMessage.value = errorDescription || error
return
}
const openid = readParam('openid')
const state = readParam('state')
const scope = readParam('scope')
const paymentType = readParam('payment_type')
const amount = readParam('amount')
const orderType = readParam('order_type')
const planId = readParam('plan_id')
const redirectURL = new URL(
normalizeRedirectPath(readParam('redirect')),
window.location.origin,
)
if (!openid) {
errorMessage.value = textWithFallback(
'auth.wechatPayment.callbackMissingOpenId',
'微信支付回调缺少 openid。',
'The WeChat payment callback is missing the openid.',
)
return
}
const query: Record<string, string> = {
...Object.fromEntries(redirectURL.searchParams.entries()),
wechat_resume: '1',
openid,
}
if (state) query.state = state
if (scope) query.scope = scope
if (paymentType) query.payment_type = paymentType
if (amount) query.amount = amount
if (orderType) query.order_type = orderType
if (planId) query.plan_id = planId
await router.replace({
path: redirectURL.pathname,
query,
})
})
</script>

View File

@@ -0,0 +1,80 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import WechatPaymentCallbackView from '@/views/auth/WechatPaymentCallbackView.vue'
const { replaceMock, routeState, locationState } = vi.hoisted(() => ({
replaceMock: vi.fn(),
routeState: {
query: {} as Record<string, unknown>,
},
locationState: {
current: {
href: 'http://localhost/auth/wechat/payment/callback',
hash: '',
search: '',
pathname: '/auth/wechat/payment/callback',
origin: 'http://localhost',
} as Location & { origin: string },
},
}))
vi.mock('vue-router', () => ({
useRoute: () => routeState,
useRouter: () => ({
replace: replaceMock,
}),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
locale: { value: 'zh-CN' },
}),
}))
describe('WechatPaymentCallbackView', () => {
beforeEach(() => {
replaceMock.mockReset()
routeState.query = {}
locationState.current = {
href: 'http://localhost/auth/wechat/payment/callback',
hash: '',
search: '',
pathname: '/auth/wechat/payment/callback',
origin: 'http://localhost',
} as Location & { origin: string }
Object.defineProperty(window, 'location', {
configurable: true,
value: locationState.current,
})
})
it('redirects back to purchase with openid and payment context from hash fragment', async () => {
locationState.current.hash = '#openid=openid-123&payment_type=wxpay&amount=12.5&order_type=balance&redirect=%2Fpurchase%3Ffrom%3Dwechat'
mount(WechatPaymentCallbackView)
await flushPromises()
expect(replaceMock).toHaveBeenCalledWith({
path: '/purchase',
query: {
from: 'wechat',
wechat_resume: '1',
openid: 'openid-123',
payment_type: 'wxpay',
amount: '12.5',
order_type: 'balance',
},
})
})
it('shows an error when the callback payload is missing openid', async () => {
locationState.current.hash = '#payment_type=wxpay'
const wrapper = mount(WechatPaymentCallbackView)
await flushPromises()
expect(replaceMock).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('微信支付回调缺少 openid。')
})
})

View File

@@ -309,6 +309,20 @@ const previewImage = ref('')
const paymentPhase = ref<'select' | 'paying'>('select')
interface CreateOrderOptions {
openid?: string
paymentType?: string
isResume?: boolean
}
interface WeixinJSBridgeLike {
invoke(
action: string,
payload: Record<string, unknown>,
callback: (result: Record<string, unknown>) => void,
): void
}
function emptyPaymentState(): PaymentRecoverySnapshot {
return {
orderId: 0,
@@ -326,6 +340,48 @@ function emptyPaymentState(): PaymentRecoverySnapshot {
}
}
function readRouteQueryValue(value: unknown): string {
if (Array.isArray(value)) {
return typeof value[0] === 'string' ? value[0] : ''
}
return typeof value === 'string' ? value : ''
}
function getWeixinJSBridge(): WeixinJSBridgeLike | undefined {
return (window as Window & { WeixinJSBridge?: WeixinJSBridgeLike }).WeixinJSBridge
}
function waitForWeixinJSBridge(timeoutMs = 4000): Promise<WeixinJSBridgeLike | null> {
const existing = getWeixinJSBridge()
if (existing) return Promise.resolve(existing)
return new Promise((resolve) => {
let settled = false
const finish = (bridge: WeixinJSBridgeLike | null) => {
if (settled) return
settled = true
document.removeEventListener('WeixinJSBridgeReady', handleReady)
document.removeEventListener('onWeixinJSBridgeReady', handleReady)
window.clearTimeout(timer)
resolve(bridge)
}
const handleReady = () => finish(getWeixinJSBridge() ?? null)
const timer = window.setTimeout(() => finish(getWeixinJSBridge() ?? null), timeoutMs)
document.addEventListener('WeixinJSBridgeReady', handleReady, false)
document.addEventListener('onWeixinJSBridgeReady', handleReady, false)
})
}
async function invokeWechatJsapiPayment(payload: Record<string, unknown>): Promise<Record<string, unknown>> {
const bridge = await waitForWeixinJSBridge()
if (!bridge) {
throw new Error('WeixinJSBridge is unavailable')
}
return new Promise((resolve) => {
bridge.invoke('getBrandWCPayRequest', payload, (result) => resolve(result || {}))
})
}
const paymentState = ref<PaymentRecoverySnapshot>(emptyPaymentState())
function persistRecoverySnapshot(snapshot: PaymentRecoverySnapshot) {
@@ -560,25 +616,32 @@ async function confirmSubscribe() {
await createOrder(selectedPlan.value.price, 'subscription', selectedPlan.value.id)
}
async function createOrder(orderAmount: number, orderType: OrderType, planId?: number) {
async function createOrder(orderAmount: number, orderType: OrderType, planId?: number, options: CreateOrderOptions = {}) {
submitting.value = true
errorMessage.value = ''
try {
const result = await paymentStore.createOrder(buildCreateOrderPayload({
const requestType = normalizeVisibleMethod(options.paymentType || selectedMethod.value) || options.paymentType || selectedMethod.value
const payload = buildCreateOrderPayload({
amount: orderAmount,
paymentType: selectedMethod.value,
paymentType: requestType,
orderType,
planId,
origin: typeof window !== 'undefined' ? window.location.origin : '',
isWechatBrowser: typeof window !== 'undefined' && /MicroMessenger/i.test(window.navigator.userAgent),
})) as CreateOrderResult & { resume_token?: string }
})
if (options.openid) {
payload.openid = options.openid
}
payload.is_mobile = isMobileDevice()
const result = await paymentStore.createOrder(payload) as CreateOrderResult & { resume_token?: string }
const openWindow = (url: string, features = POPUP_WINDOW_FEATURES) => {
const win = window.open(url, 'paymentPopup', features)
if (!win || win.closed) {
window.location.href = url
}
}
const visibleMethod = normalizeVisibleMethod(selectedMethod.value) || selectedMethod.value
const visibleMethod = normalizeVisibleMethod(requestType) || requestType
const stripeMethod = visibleMethod === 'wxpay' ? 'wechat_pay' : 'alipay'
const stripeRouteUrl = result.client_secret
? router.resolve({
@@ -599,6 +662,11 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
stripeRouteUrl,
})
if (decision.kind === 'wechat_oauth' && decision.oauth?.authorize_url) {
window.location.href = decision.oauth.authorize_url
return
}
if (decision.kind === 'unhandled') {
errorMessage.value = t('payment.result.failed')
appStore.showError(errorMessage.value)
@@ -617,6 +685,16 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
window.location.href = decision.paymentState.payUrl
return
}
if (decision.kind === 'wechat_jsapi' && decision.jsapi) {
const jsapiResult = await invokeWechatJsapiPayment(decision.jsapi as Record<string, unknown>)
const errMsg = String(jsapiResult.err_msg || '').toLowerCase()
if (errMsg.includes('cancel')) {
appStore.showInfo(t('payment.qr.cancelled'))
} else if (errMsg && !errMsg.includes('ok')) {
appStore.showError(t('payment.result.failed'))
}
return
}
if (decision.kind === 'redirect_waiting' && decision.paymentState.payUrl) {
if (isMobileDevice()) {
window.location.href = decision.paymentState.payUrl
@@ -640,6 +718,50 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
}
}
async function resumeWechatPaymentFromQuery() {
const openid = readRouteQueryValue(route.query.openid)
if (readRouteQueryValue(route.query.wechat_resume) !== '1' || !openid) {
return
}
const paymentType = normalizeVisibleMethod(readRouteQueryValue(route.query.payment_type)) || 'wxpay'
const orderType = readRouteQueryValue(route.query.order_type) === 'subscription' ? 'subscription' : 'balance'
const planId = Number.parseInt(readRouteQueryValue(route.query.plan_id), 10)
const rawAmount = Number.parseFloat(readRouteQueryValue(route.query.amount))
const orderAmount = Number.isFinite(rawAmount) && rawAmount > 0
? rawAmount
: (orderType === 'subscription'
? (checkout.value.plans.find(plan => plan.id === planId)?.price ?? 0)
: validAmount.value)
selectedMethod.value = paymentType
if (orderType === 'balance' && orderAmount > 0) {
amount.value = orderAmount
}
if (orderType === 'subscription' && Number.isFinite(planId) && planId > 0) {
selectedPlan.value = checkout.value.plans.find(plan => plan.id === planId) ?? null
}
const nextQuery = { ...route.query }
delete nextQuery.wechat_resume
delete nextQuery.openid
delete nextQuery.state
delete nextQuery.scope
delete nextQuery.payment_type
delete nextQuery.amount
delete nextQuery.order_type
delete nextQuery.plan_id
await router.replace({ path: route.path, query: nextQuery })
if (orderAmount > 0) {
await createOrder(orderAmount, orderType, Number.isFinite(planId) && planId > 0 ? planId : undefined, {
openid,
paymentType,
isResume: true,
})
}
}
onMounted(async () => {
try {
const res = await paymentAPI.getCheckoutInfo()
@@ -672,6 +794,7 @@ onMounted(async () => {
removeRecoverySnapshot()
}
}
await resumeWechatPaymentFromQuery()
if (checkout.value.balance_disabled) {
activeTab.value = 'subscription'
}