Tighten WeChat payment resume flow
This commit is contained in:
@@ -157,6 +157,7 @@ export interface CreateOrderRequest {
|
||||
return_url?: string
|
||||
payment_source?: string
|
||||
openid?: string
|
||||
wechat_resume_token?: string
|
||||
is_mobile?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -114,23 +114,17 @@ onMounted(async () => {
|
||||
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 resumeToken = readParam('wechat_resume_token')
|
||||
const redirectURL = new URL(
|
||||
normalizeRedirectPath(readParam('redirect')),
|
||||
window.location.origin,
|
||||
)
|
||||
|
||||
if (!openid) {
|
||||
if (!resumeToken) {
|
||||
errorMessage.value = textWithFallback(
|
||||
'auth.wechatPayment.callbackMissingOpenId',
|
||||
'微信支付回调缺少 openid。',
|
||||
'The WeChat payment callback is missing the openid.',
|
||||
'auth.wechatPayment.callbackMissingResumeToken',
|
||||
'微信支付回调缺少恢复令牌。',
|
||||
'The WeChat payment callback is missing the resume token.',
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -138,14 +132,8 @@ onMounted(async () => {
|
||||
const query: Record<string, string> = {
|
||||
...Object.fromEntries(redirectURL.searchParams.entries()),
|
||||
wechat_resume: '1',
|
||||
openid,
|
||||
wechat_resume_token: resumeToken,
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -49,8 +49,8 @@ describe('WechatPaymentCallbackView', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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'
|
||||
it('redirects back to purchase with an opaque resume token from hash fragment', async () => {
|
||||
locationState.current.hash = '#wechat_resume_token=resume-token-123&redirect=%2Fpurchase%3Ffrom%3Dwechat'
|
||||
|
||||
mount(WechatPaymentCallbackView)
|
||||
await flushPromises()
|
||||
@@ -60,21 +60,18 @@ describe('WechatPaymentCallbackView', () => {
|
||||
query: {
|
||||
from: 'wechat',
|
||||
wechat_resume: '1',
|
||||
openid: 'openid-123',
|
||||
payment_type: 'wxpay',
|
||||
amount: '12.5',
|
||||
order_type: 'balance',
|
||||
wechat_resume_token: 'resume-token-123',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('shows an error when the callback payload is missing openid', async () => {
|
||||
it('shows an error when the callback payload is missing the resume token', async () => {
|
||||
locationState.current.hash = '#payment_type=wxpay'
|
||||
|
||||
const wrapper = mount(WechatPaymentCallbackView)
|
||||
await flushPromises()
|
||||
|
||||
expect(replaceMock).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('微信支付回调缺少 openid。')
|
||||
expect(wrapper.text()).toContain('微信支付回调缺少恢复令牌。')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -188,7 +188,8 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const hasLegacyFallbackContext = Boolean(route.query.trade_status || route.query.money || route.query.type)
|
||||
const hasLegacyFallbackContext = typeof route.query.trade_status === 'string'
|
||||
&& route.query.trade_status.trim() !== ''
|
||||
if (!order.value && !resumeToken && !orderId && outTradeNo && hasLegacyFallbackContext) {
|
||||
returnInfo.value = {
|
||||
outTradeNo,
|
||||
|
||||
@@ -284,6 +284,7 @@ import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
|
||||
import { describePaymentScenarioError } from './paymentUx'
|
||||
import { parseWechatResumeRoute, stripWechatResumeQuery } from './paymentWechatResume'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
@@ -315,6 +316,7 @@ const paymentPhase = ref<'select' | 'paying'>('select')
|
||||
|
||||
interface CreateOrderOptions {
|
||||
openid?: string
|
||||
wechatResumeToken?: string
|
||||
paymentType?: string
|
||||
isResume?: boolean
|
||||
}
|
||||
@@ -344,13 +346,6 @@ 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
|
||||
}
|
||||
@@ -637,6 +632,9 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
||||
if (options.openid) {
|
||||
payload.openid = options.openid
|
||||
}
|
||||
if (options.wechatResumeToken) {
|
||||
payload.wechat_resume_token = options.wechatResumeToken
|
||||
}
|
||||
payload.is_mobile = isMobileDevice()
|
||||
|
||||
const result = await paymentStore.createOrder(payload) as CreateOrderResult & { resume_token?: string }
|
||||
@@ -744,44 +742,34 @@ function applyScenarioError(err: unknown, paymentMethod: string) {
|
||||
}
|
||||
|
||||
async function resumeWechatPaymentFromQuery() {
|
||||
const openid = readRouteQueryValue(route.query.openid)
|
||||
if (readRouteQueryValue(route.query.wechat_resume) !== '1' || !openid) {
|
||||
const resume = parseWechatResumeRoute(route.query, checkout.value.plans, validAmount.value)
|
||||
if (!resume) {
|
||||
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
|
||||
selectedMethod.value = resume.paymentType
|
||||
if (resume.orderType === 'balance' && resume.orderAmount > 0) {
|
||||
amount.value = resume.orderAmount
|
||||
}
|
||||
if (orderType === 'subscription' && Number.isFinite(planId) && planId > 0) {
|
||||
selectedPlan.value = checkout.value.plans.find(plan => plan.id === planId) ?? null
|
||||
if (resume.orderType === 'subscription' && resume.planId) {
|
||||
selectedPlan.value = checkout.value.plans.find(plan => plan.id === resume.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 })
|
||||
await router.replace({ path: route.path, query: stripWechatResumeQuery(route.query) })
|
||||
|
||||
if (orderAmount > 0) {
|
||||
await createOrder(orderAmount, orderType, Number.isFinite(planId) && planId > 0 ? planId : undefined, {
|
||||
openid,
|
||||
paymentType,
|
||||
if (resume.wechatResumeToken) {
|
||||
await createOrder(0, resume.orderType, resume.planId, {
|
||||
wechatResumeToken: resume.wechatResumeToken,
|
||||
paymentType: resume.paymentType,
|
||||
isResume: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (resume.orderAmount > 0 && resume.openid) {
|
||||
await createOrder(resume.orderAmount, resume.orderType, resume.planId, {
|
||||
openid: resume.openid,
|
||||
paymentType: resume.paymentType,
|
||||
isResume: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -157,6 +157,25 @@ describe('PaymentResultView', () => {
|
||||
expect(wrapper.text()).toContain('payment.result.success')
|
||||
})
|
||||
|
||||
it('does not use public out_trade_no verification for bare order numbers without legacy return markers', async () => {
|
||||
routeState.query = {
|
||||
out_trade_no: 'legacy-bare',
|
||||
}
|
||||
|
||||
mount(PaymentResultView, {
|
||||
global: {
|
||||
stubs: {
|
||||
OrderStatusBadge: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(verifyOrderPublic).not.toHaveBeenCalled()
|
||||
expect(verifyOrder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves order by resume token when local recovery snapshot is missing', async () => {
|
||||
routeState.query = {
|
||||
resume_token: 'resume-77',
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseWechatResumeRoute, stripWechatResumeQuery } from '../paymentWechatResume'
|
||||
|
||||
describe('parseWechatResumeRoute', () => {
|
||||
it('prefers the opaque resume token over legacy openid query params', () => {
|
||||
expect(parseWechatResumeRoute({
|
||||
wechat_resume: '1',
|
||||
wechat_resume_token: 'resume-token-123',
|
||||
openid: 'openid-123',
|
||||
payment_type: 'wxpay',
|
||||
amount: '12.5',
|
||||
order_type: 'subscription',
|
||||
plan_id: '7',
|
||||
}, [], 88)).toEqual({
|
||||
wechatResumeToken: 'resume-token-123',
|
||||
paymentType: 'wxpay',
|
||||
orderType: 'balance',
|
||||
orderAmount: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to legacy openid-based resume when opaque token is absent', () => {
|
||||
expect(parseWechatResumeRoute({
|
||||
wechat_resume: '1',
|
||||
openid: 'openid-123',
|
||||
payment_type: 'wxpay',
|
||||
amount: '12.5',
|
||||
order_type: 'balance',
|
||||
}, [], 88)).toEqual({
|
||||
openid: 'openid-123',
|
||||
paymentType: 'wxpay',
|
||||
orderType: 'balance',
|
||||
orderAmount: 12.5,
|
||||
planId: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripWechatResumeQuery', () => {
|
||||
it('removes both opaque-token and legacy resume params from the route query', () => {
|
||||
expect(stripWechatResumeQuery({
|
||||
foo: 'bar',
|
||||
wechat_resume: '1',
|
||||
wechat_resume_token: 'resume-token-123',
|
||||
openid: 'openid-123',
|
||||
payment_type: 'wxpay',
|
||||
amount: '12.5',
|
||||
order_type: 'subscription',
|
||||
plan_id: '7',
|
||||
state: 'state-123',
|
||||
scope: 'snsapi_base',
|
||||
})).toEqual({
|
||||
foo: 'bar',
|
||||
})
|
||||
})
|
||||
})
|
||||
77
frontend/src/views/user/paymentWechatResume.ts
Normal file
77
frontend/src/views/user/paymentWechatResume.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { LocationQuery, LocationQueryRaw } from 'vue-router'
|
||||
import type { SubscriptionPlan } from '@/types/payment'
|
||||
import { normalizeVisibleMethod } from '@/components/payment/paymentFlow'
|
||||
|
||||
export interface ParsedWechatResumeRoute {
|
||||
orderAmount: number
|
||||
orderType: 'balance' | 'subscription'
|
||||
paymentType: string
|
||||
planId?: number
|
||||
openid?: string
|
||||
wechatResumeToken?: string
|
||||
}
|
||||
|
||||
function readQueryString(query: LocationQuery, key: string): string {
|
||||
const value = query[key]
|
||||
if (Array.isArray(value)) {
|
||||
return typeof value[0] === 'string' ? value[0] : ''
|
||||
}
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
export function parseWechatResumeRoute(
|
||||
query: LocationQuery,
|
||||
plans: SubscriptionPlan[],
|
||||
fallbackBalanceAmount: number,
|
||||
): ParsedWechatResumeRoute | null {
|
||||
if (readQueryString(query, 'wechat_resume') !== '1') {
|
||||
return null
|
||||
}
|
||||
|
||||
const wechatResumeToken = readQueryString(query, 'wechat_resume_token')
|
||||
if (wechatResumeToken) {
|
||||
return {
|
||||
wechatResumeToken,
|
||||
paymentType: 'wxpay',
|
||||
orderType: 'balance',
|
||||
orderAmount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const openid = readQueryString(query, 'openid')
|
||||
if (!openid) {
|
||||
return null
|
||||
}
|
||||
|
||||
const paymentType = normalizeVisibleMethod(readQueryString(query, 'payment_type')) || 'wxpay'
|
||||
const orderType = readQueryString(query, 'order_type') === 'subscription' ? 'subscription' : 'balance'
|
||||
const planId = Number.parseInt(readQueryString(query, 'plan_id'), 10)
|
||||
const rawAmount = Number.parseFloat(readQueryString(query, 'amount'))
|
||||
const orderAmount = Number.isFinite(rawAmount) && rawAmount > 0
|
||||
? rawAmount
|
||||
: (orderType === 'subscription'
|
||||
? (plans.find(plan => plan.id === planId)?.price ?? 0)
|
||||
: fallbackBalanceAmount)
|
||||
|
||||
return {
|
||||
openid,
|
||||
paymentType,
|
||||
orderType,
|
||||
orderAmount,
|
||||
planId: Number.isFinite(planId) && planId > 0 ? planId : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function stripWechatResumeQuery(query: LocationQuery): LocationQueryRaw {
|
||||
const nextQuery: LocationQueryRaw = { ...query }
|
||||
delete nextQuery.wechat_resume
|
||||
delete nextQuery.wechat_resume_token
|
||||
delete nextQuery.openid
|
||||
delete nextQuery.state
|
||||
delete nextQuery.scope
|
||||
delete nextQuery.payment_type
|
||||
delete nextQuery.amount
|
||||
delete nextQuery.order_type
|
||||
delete nextQuery.plan_id
|
||||
return nextQuery
|
||||
}
|
||||
Reference in New Issue
Block a user