mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 23:22:59 +08:00
fix(payment): load Stripe client secret from the local order snapshot
The /payment/stripe route no longer carries client_secret in its query. The page reads it from the recovery snapshot written when the order was created and requires the snapshot order to match order_id, consistent with AirwallexPaymentView. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
shaw
co-authored by
Claude Opus 5.5
parent
7c6f70680d
commit
f996b0bfaa
@@ -874,7 +874,6 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
||||
path: '/payment/stripe',
|
||||
query: {
|
||||
order_id: String(result.order_id),
|
||||
client_secret: result.client_secret,
|
||||
method: stripeMethod || undefined,
|
||||
resume_token: result.resume_token || undefined,
|
||||
},
|
||||
@@ -1082,7 +1081,6 @@ async function attemptMobileQrFallback(err: unknown, context: MobileQrFallbackCo
|
||||
path: '/payment/stripe',
|
||||
query: {
|
||||
order_id: String(result.order_id),
|
||||
client_secret: result.client_secret,
|
||||
method: stripeMethod,
|
||||
resume_token: result.resume_token || undefined,
|
||||
},
|
||||
|
||||
@@ -136,9 +136,14 @@ let redirectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
const orderId = Number(route.query.order_id)
|
||||
const clientSecret = String(route.query.client_secret || '')
|
||||
const method = String(route.query.method || '')
|
||||
const resumeToken = typeof route.query.resume_token === 'string' ? route.query.resume_token : undefined
|
||||
// client_secret 只从本浏览器下单时写入的恢复快照读取,且快照订单必须与 URL 订单一致:
|
||||
// 不信任 URL 中的 client_secret,避免页面展示的订单与实际收款的 PaymentIntent 被拼接错绑。
|
||||
const restored = typeof window !== 'undefined'
|
||||
? readPaymentRecoverySnapshot(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY), { resumeToken })
|
||||
: null
|
||||
const clientSecret = orderId && restored?.orderId === orderId ? restored.clientSecret : ''
|
||||
|
||||
if (!orderId || !clientSecret) {
|
||||
loading.value = false
|
||||
@@ -147,15 +152,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
const restored = readPaymentRecoverySnapshot(
|
||||
window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY),
|
||||
{ resumeToken },
|
||||
)
|
||||
if (restored?.orderId === orderId) {
|
||||
currency.value = normalizePaymentCurrency(restored.currency)
|
||||
}
|
||||
}
|
||||
currency.value = normalizePaymentCurrency(restored?.currency)
|
||||
const res = await paymentAPI.getOrder(orderId)
|
||||
order.value = res.data
|
||||
if (res.data.currency) {
|
||||
|
||||
@@ -65,6 +65,10 @@ vi.mock('@stripe/stripe-js/pure', () => ({
|
||||
|
||||
import StripePaymentView from '../StripePaymentView.vue'
|
||||
import { formatPaymentAmount } from '@/components/payment/currency'
|
||||
import {
|
||||
PAYMENT_RECOVERY_STORAGE_KEY,
|
||||
type PaymentRecoverySnapshot,
|
||||
} from '@/components/payment/paymentFlow'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
|
||||
function orderFactory(overrides: Partial<PaymentOrder> = {}): PaymentOrder {
|
||||
@@ -86,6 +90,33 @@ function orderFactory(overrides: Partial<PaymentOrder> = {}): PaymentOrder {
|
||||
}
|
||||
}
|
||||
|
||||
function stripeSnapshot(overrides: Partial<PaymentRecoverySnapshot> = {}): PaymentRecoverySnapshot {
|
||||
return {
|
||||
orderId: 42,
|
||||
amount: 100,
|
||||
qrCode: '',
|
||||
expiresAt: '2099-01-01T00:30:00.000Z',
|
||||
paymentType: 'stripe',
|
||||
payUrl: '/payment/stripe?order_id=42',
|
||||
outTradeNo: 'sub2_stripe_42',
|
||||
clientSecret: 'pi_secret_42',
|
||||
intentId: '',
|
||||
currency: 'CNY',
|
||||
countryCode: '',
|
||||
paymentEnv: '',
|
||||
payAmount: 103,
|
||||
orderType: 'balance',
|
||||
paymentMode: '',
|
||||
resumeToken: '',
|
||||
createdAt: Date.UTC(2099, 0, 1, 0, 0, 0),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function writeSnapshot(overrides: Partial<PaymentRecoverySnapshot> = {}) {
|
||||
window.localStorage.setItem(PAYMENT_RECOVERY_STORAGE_KEY, JSON.stringify(stripeSnapshot(overrides)))
|
||||
}
|
||||
|
||||
function mountView() {
|
||||
return shallowMount(StripePaymentView, {
|
||||
global: {
|
||||
@@ -101,7 +132,6 @@ describe('StripePaymentView', () => {
|
||||
beforeEach(() => {
|
||||
routeState.query = {
|
||||
order_id: '42',
|
||||
client_secret: 'pi_secret_42',
|
||||
}
|
||||
routerPush.mockReset()
|
||||
getOrder.mockReset().mockResolvedValue({ data: orderFactory() })
|
||||
@@ -119,6 +149,42 @@ describe('StripePaymentView', () => {
|
||||
stripeInstance.confirmAlipayPayment.mockReset()
|
||||
stripeInstance.confirmWechatPayPayment.mockReset()
|
||||
window.localStorage.clear()
|
||||
writeSnapshot()
|
||||
})
|
||||
|
||||
it('uses the client secret from the local snapshot of the same order', async () => {
|
||||
routeState.query.method = 'alipay'
|
||||
mountView()
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
expect(getOrder).toHaveBeenCalledWith(42)
|
||||
expect(stripeInstance.confirmAlipayPayment).toHaveBeenCalledWith('pi_secret_42', expect.any(Object))
|
||||
})
|
||||
|
||||
it('ignores a client secret supplied in the URL query', async () => {
|
||||
window.localStorage.clear()
|
||||
routeState.query = { order_id: '42', client_secret: 'pi_other_secret', method: 'alipay' }
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
expect(getOrder).not.toHaveBeenCalled()
|
||||
expect(loadStripe).not.toHaveBeenCalled()
|
||||
expect(stripeInstance.confirmAlipayPayment).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('payment.stripeMissingParams')
|
||||
})
|
||||
|
||||
it('rejects a snapshot that belongs to a different order', async () => {
|
||||
writeSnapshot({ orderId: 7, clientSecret: 'pi_secret_7' })
|
||||
routeState.query = { order_id: '42', client_secret: 'pi_secret_42', method: 'alipay' }
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
expect(getOrder).not.toHaveBeenCalled()
|
||||
expect(stripeInstance.confirmAlipayPayment).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('payment.stripeMissingParams')
|
||||
})
|
||||
|
||||
it.each(['alipay', 'wechat_pay', ''])('does not start %s payment after leaving during SDK loading', async (method) => {
|
||||
@@ -178,7 +244,8 @@ describe('StripePaymentView', () => {
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('本地恢复快照缺失时使用订单接口返回的 Stripe 币种展示金额', async () => {
|
||||
it('本地恢复快照未记录币种时使用订单接口返回的 Stripe 币种展示金额', async () => {
|
||||
writeSnapshot({ currency: '' })
|
||||
getOrder.mockResolvedValue({
|
||||
data: orderFactory({ currency: 'HKD', pay_amount: 103 }),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user