feat: rebuild auth identity foundation flow
This commit is contained in:
@@ -141,7 +141,9 @@ const props = defineProps<{
|
||||
orderType?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ done: []; success: [] }>()
|
||||
type PaymentOutcome = 'success' | 'cancelled' | 'expired'
|
||||
|
||||
const emit = defineEmits<{ done: []; success: []; settled: [outcome: PaymentOutcome] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const paymentStore = usePaymentStore()
|
||||
@@ -154,7 +156,7 @@ const cancelling = ref(false)
|
||||
const paidOrder = ref<PaymentOrder | null>(null)
|
||||
|
||||
// Terminal outcome: null = still active, 'success' | 'cancelled' | 'expired'
|
||||
const outcome = ref<'success' | 'cancelled' | 'expired' | null>(null)
|
||||
const outcome = ref<PaymentOutcome | null>(null)
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
@@ -194,10 +196,19 @@ const countdownDisplay = computed(() => {
|
||||
|
||||
function reopenPopup() {
|
||||
if (props.payUrl) {
|
||||
window.open(props.payUrl, 'paymentPopup', POPUP_WINDOW_FEATURES)
|
||||
const win = window.open(props.payUrl, 'paymentPopup', POPUP_WINDOW_FEATURES)
|
||||
if (!win || win.closed) {
|
||||
window.location.href = props.payUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setOutcome(next: PaymentOutcome) {
|
||||
if (outcome.value === next) return
|
||||
outcome.value = next
|
||||
emit('settled', next)
|
||||
}
|
||||
|
||||
async function renderQR() {
|
||||
await nextTick()
|
||||
if (!qrCanvas.value || !qrUrl.value) return
|
||||
@@ -214,23 +225,23 @@ async function pollStatus() {
|
||||
if (order.status === 'COMPLETED' || order.status === 'PAID') {
|
||||
cleanup()
|
||||
paidOrder.value = order
|
||||
outcome.value = 'success'
|
||||
setOutcome('success')
|
||||
emit('success')
|
||||
} else if (order.status === 'CANCELLED') {
|
||||
cleanup()
|
||||
outcome.value = 'cancelled'
|
||||
setOutcome('cancelled')
|
||||
} else if (order.status === 'EXPIRED' || order.status === 'FAILED') {
|
||||
cleanup()
|
||||
outcome.value = 'expired'
|
||||
setOutcome('expired')
|
||||
}
|
||||
}
|
||||
|
||||
function startCountdown(seconds: number) {
|
||||
remainingSeconds.value = Math.max(0, seconds)
|
||||
if (remainingSeconds.value <= 0) { outcome.value = 'expired'; return }
|
||||
if (remainingSeconds.value <= 0) { setOutcome('expired'); return }
|
||||
countdownTimer = setInterval(() => {
|
||||
remainingSeconds.value--
|
||||
if (remainingSeconds.value <= 0) { outcome.value = 'expired'; cleanup() }
|
||||
if (remainingSeconds.value <= 0) { setOutcome('expired'); cleanup() }
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
@@ -240,7 +251,7 @@ async function handleCancel() {
|
||||
try {
|
||||
await paymentAPI.cancelOrder(props.orderId)
|
||||
cleanup()
|
||||
outcome.value = 'cancelled'
|
||||
setOutcome('cancelled')
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
|
||||
163
frontend/src/components/payment/__tests__/paymentFlow.spec.ts
Normal file
163
frontend/src/components/payment/__tests__/paymentFlow.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { CreateOrderResult, MethodLimit } from '@/types/payment'
|
||||
import {
|
||||
decidePaymentLaunch,
|
||||
getVisibleMethods,
|
||||
readPaymentRecoverySnapshot,
|
||||
type PaymentRecoverySnapshot,
|
||||
} from '@/components/payment/paymentFlow'
|
||||
|
||||
function methodLimit(overrides: Partial<MethodLimit> = {}): MethodLimit {
|
||||
return {
|
||||
daily_limit: 0,
|
||||
daily_used: 0,
|
||||
daily_remaining: 0,
|
||||
single_min: 0,
|
||||
single_max: 0,
|
||||
fee_rate: 0,
|
||||
available: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createOrderResult(overrides: Partial<CreateOrderResult> = {}): CreateOrderResult {
|
||||
return {
|
||||
order_id: 101,
|
||||
amount: 88,
|
||||
pay_amount: 88,
|
||||
fee_rate: 0,
|
||||
expires_at: '2099-01-01T00:10:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('getVisibleMethods', () => {
|
||||
it('filters hidden provider methods and normalizes aliases', () => {
|
||||
const visible = getVisibleMethods({
|
||||
alipay_direct: methodLimit({ single_min: 5 }),
|
||||
wxpay: methodLimit({ single_max: 100 }),
|
||||
stripe: methodLimit({ fee_rate: 3 }),
|
||||
})
|
||||
|
||||
expect(visible).toEqual({
|
||||
alipay: methodLimit({ single_min: 5 }),
|
||||
wxpay: methodLimit({ single_max: 100 }),
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers canonical visible methods over aliases when both exist', () => {
|
||||
const visible = getVisibleMethods({
|
||||
alipay: methodLimit({ single_min: 2 }),
|
||||
alipay_direct: methodLimit({ single_min: 9 }),
|
||||
wxpay_direct: methodLimit({ fee_rate: 1.2 }),
|
||||
})
|
||||
|
||||
expect(visible.alipay.single_min).toBe(2)
|
||||
expect(visible.wxpay.fee_rate).toBe(1.2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('decidePaymentLaunch', () => {
|
||||
it('uses Stripe popup waiting flow for desktop Alipay client secret', () => {
|
||||
const decision = decidePaymentLaunch(createOrderResult({
|
||||
client_secret: 'cs_test',
|
||||
resume_token: 'resume-1',
|
||||
}), {
|
||||
visibleMethod: 'alipay',
|
||||
orderType: 'balance',
|
||||
isMobile: false,
|
||||
})
|
||||
|
||||
expect(decision.kind).toBe('stripe_popup')
|
||||
expect(decision.paymentState.paymentType).toBe('alipay')
|
||||
expect(decision.stripeMethod).toBe('alipay')
|
||||
expect(decision.recovery.resumeToken).toBe('resume-1')
|
||||
})
|
||||
|
||||
it('uses Stripe route flow for mobile WeChat client secret', () => {
|
||||
const decision = decidePaymentLaunch(createOrderResult({
|
||||
client_secret: 'cs_test',
|
||||
}), {
|
||||
visibleMethod: 'wxpay',
|
||||
orderType: 'subscription',
|
||||
isMobile: true,
|
||||
})
|
||||
|
||||
expect(decision.kind).toBe('stripe_route')
|
||||
expect(decision.stripeMethod).toBe('wechat_pay')
|
||||
expect(decision.paymentState.orderType).toBe('subscription')
|
||||
})
|
||||
|
||||
it('keeps hosted redirect metadata for recovery flows', () => {
|
||||
const decision = decidePaymentLaunch(createOrderResult({
|
||||
pay_url: 'https://pay.example.com/session/abc',
|
||||
payment_mode: 'popup',
|
||||
resume_token: 'resume-2',
|
||||
}), {
|
||||
visibleMethod: 'wxpay',
|
||||
orderType: 'balance',
|
||||
isMobile: false,
|
||||
})
|
||||
|
||||
expect(decision.kind).toBe('redirect_waiting')
|
||||
expect(decision.paymentState.payUrl).toBe('https://pay.example.com/session/abc')
|
||||
expect(decision.recovery.paymentMode).toBe('popup')
|
||||
expect(decision.recovery.resumeToken).toBe('resume-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('readPaymentRecoverySnapshot', () => {
|
||||
it('restores an unexpired snapshot when the resume token matches', () => {
|
||||
const snapshot: PaymentRecoverySnapshot = {
|
||||
orderId: 33,
|
||||
amount: 18,
|
||||
qrCode: '',
|
||||
expiresAt: '2099-01-01T00:10:00.000Z',
|
||||
paymentType: 'alipay',
|
||||
payUrl: 'https://pay.example.com/session/33',
|
||||
clientSecret: '',
|
||||
payAmount: 18,
|
||||
orderType: 'balance',
|
||||
paymentMode: 'popup',
|
||||
resumeToken: 'resume-33',
|
||||
createdAt: Date.UTC(2099, 0, 1, 0, 0, 0),
|
||||
}
|
||||
|
||||
const restored = readPaymentRecoverySnapshot(JSON.stringify(snapshot), {
|
||||
now: Date.UTC(2099, 0, 1, 0, 1, 0),
|
||||
resumeToken: 'resume-33',
|
||||
})
|
||||
|
||||
expect(restored?.orderId).toBe(33)
|
||||
})
|
||||
|
||||
it('drops expired or mismatched recovery snapshots', () => {
|
||||
const expiredSnapshot: PaymentRecoverySnapshot = {
|
||||
orderId: 55,
|
||||
amount: 18,
|
||||
qrCode: '',
|
||||
expiresAt: '2024-01-01T00:10:00.000Z',
|
||||
paymentType: 'wxpay',
|
||||
payUrl: 'https://pay.example.com/session/55',
|
||||
clientSecret: '',
|
||||
payAmount: 18,
|
||||
orderType: 'balance',
|
||||
paymentMode: 'popup',
|
||||
resumeToken: 'resume-55',
|
||||
createdAt: Date.UTC(2024, 0, 1, 0, 0, 0),
|
||||
}
|
||||
|
||||
expect(readPaymentRecoverySnapshot(JSON.stringify(expiredSnapshot), {
|
||||
now: Date.UTC(2024, 0, 1, 0, 20, 0),
|
||||
resumeToken: 'resume-55',
|
||||
})).toBeNull()
|
||||
|
||||
expect(readPaymentRecoverySnapshot(JSON.stringify({
|
||||
...expiredSnapshot,
|
||||
expiresAt: '2099-01-01T00:10:00.000Z',
|
||||
}), {
|
||||
now: Date.UTC(2099, 0, 1, 0, 1, 0),
|
||||
resumeToken: 'other-token',
|
||||
})).toBeNull()
|
||||
})
|
||||
})
|
||||
197
frontend/src/components/payment/paymentFlow.ts
Normal file
197
frontend/src/components/payment/paymentFlow.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type { CreateOrderResult, MethodLimit, OrderType } from '@/types/payment'
|
||||
|
||||
export const PAYMENT_RECOVERY_STORAGE_KEY = 'payment.recovery.current'
|
||||
|
||||
const VISIBLE_METHOD_ALIASES = {
|
||||
alipay: 'alipay',
|
||||
alipay_direct: 'alipay',
|
||||
wxpay: 'wxpay',
|
||||
wxpay_direct: 'wxpay',
|
||||
} as const
|
||||
|
||||
export type VisiblePaymentMethod = 'alipay' | 'wxpay'
|
||||
export type StripeVisibleMethod = 'alipay' | 'wechat_pay'
|
||||
export type PaymentLaunchKind =
|
||||
| 'qr_waiting'
|
||||
| 'redirect_waiting'
|
||||
| 'stripe_popup'
|
||||
| 'stripe_route'
|
||||
| 'unhandled'
|
||||
|
||||
export interface PaymentRecoverySnapshot {
|
||||
orderId: number
|
||||
amount: number
|
||||
qrCode: string
|
||||
expiresAt: string
|
||||
paymentType: string
|
||||
payUrl: string
|
||||
clientSecret: string
|
||||
payAmount: number
|
||||
orderType: OrderType | ''
|
||||
paymentMode: string
|
||||
resumeToken: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export interface PaymentLaunchContext {
|
||||
visibleMethod: string
|
||||
orderType: OrderType
|
||||
isMobile: boolean
|
||||
now?: number
|
||||
stripePopupUrl?: string
|
||||
stripeRouteUrl?: string
|
||||
}
|
||||
|
||||
export interface PaymentLaunchDecision {
|
||||
kind: PaymentLaunchKind
|
||||
paymentState: PaymentRecoverySnapshot
|
||||
recovery: PaymentRecoverySnapshot
|
||||
stripeMethod?: StripeVisibleMethod
|
||||
}
|
||||
|
||||
type CreateOrderFlowResult = CreateOrderResult & {
|
||||
resume_token?: string
|
||||
}
|
||||
|
||||
type StorageWriter = Pick<Storage, 'removeItem' | 'setItem'>
|
||||
|
||||
export function normalizeVisibleMethod(method: string): VisiblePaymentMethod | '' {
|
||||
const normalized = VISIBLE_METHOD_ALIASES[method.trim() as keyof typeof VISIBLE_METHOD_ALIASES]
|
||||
return normalized ?? ''
|
||||
}
|
||||
|
||||
export function getVisibleMethods(methods: Record<string, MethodLimit>): Record<string, MethodLimit> {
|
||||
const visible: Record<string, MethodLimit> = {}
|
||||
|
||||
Object.entries(methods).forEach(([type, limit]) => {
|
||||
const normalized = normalizeVisibleMethod(type)
|
||||
if (!normalized) return
|
||||
|
||||
const isCanonical = type === normalized
|
||||
const existing = visible[normalized]
|
||||
if (!existing || isCanonical) {
|
||||
visible[normalized] = { ...limit }
|
||||
}
|
||||
})
|
||||
|
||||
return visible
|
||||
}
|
||||
|
||||
export function decidePaymentLaunch(
|
||||
result: CreateOrderFlowResult,
|
||||
context: PaymentLaunchContext,
|
||||
): PaymentLaunchDecision {
|
||||
const visibleMethod = normalizeVisibleMethod(context.visibleMethod) || context.visibleMethod
|
||||
const baseState = createPaymentRecoverySnapshot({
|
||||
orderId: result.order_id,
|
||||
amount: result.amount,
|
||||
qrCode: result.qr_code || '',
|
||||
expiresAt: result.expires_at || '',
|
||||
paymentType: visibleMethod,
|
||||
payUrl: result.pay_url || '',
|
||||
clientSecret: result.client_secret || '',
|
||||
payAmount: result.pay_amount,
|
||||
orderType: context.orderType,
|
||||
paymentMode: (result.payment_mode || '').trim(),
|
||||
resumeToken: result.resume_token || '',
|
||||
}, context.now)
|
||||
|
||||
if (baseState.clientSecret) {
|
||||
const stripeMethod: StripeVisibleMethod = visibleMethod === 'wxpay' ? 'wechat_pay' : 'alipay'
|
||||
const kind: PaymentLaunchKind = stripeMethod === 'alipay' && !context.isMobile
|
||||
? 'stripe_popup'
|
||||
: 'stripe_route'
|
||||
const payUrl = kind === 'stripe_popup'
|
||||
? context.stripePopupUrl || context.stripeRouteUrl || ''
|
||||
: context.stripeRouteUrl || context.stripePopupUrl || ''
|
||||
const paymentState = { ...baseState, payUrl }
|
||||
return { kind, paymentState, recovery: paymentState, stripeMethod }
|
||||
}
|
||||
|
||||
if (baseState.qrCode) {
|
||||
return { kind: 'qr_waiting', paymentState: baseState, recovery: baseState }
|
||||
}
|
||||
|
||||
if (baseState.payUrl) {
|
||||
return { kind: 'redirect_waiting', paymentState: baseState, recovery: baseState }
|
||||
}
|
||||
|
||||
return { kind: 'unhandled', paymentState: baseState, recovery: baseState }
|
||||
}
|
||||
|
||||
export function createPaymentRecoverySnapshot(
|
||||
state: Omit<PaymentRecoverySnapshot, 'createdAt'>,
|
||||
now = Date.now(),
|
||||
): PaymentRecoverySnapshot {
|
||||
return {
|
||||
...state,
|
||||
createdAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
export function writePaymentRecoverySnapshot(
|
||||
storage: StorageWriter,
|
||||
snapshot: PaymentRecoverySnapshot,
|
||||
key = PAYMENT_RECOVERY_STORAGE_KEY,
|
||||
): void {
|
||||
storage.setItem(key, JSON.stringify(snapshot))
|
||||
}
|
||||
|
||||
export function clearPaymentRecoverySnapshot(
|
||||
storage: Pick<Storage, 'removeItem'>,
|
||||
key = PAYMENT_RECOVERY_STORAGE_KEY,
|
||||
): void {
|
||||
storage.removeItem(key)
|
||||
}
|
||||
|
||||
export function readPaymentRecoverySnapshot(
|
||||
raw: string | null | undefined,
|
||||
options: { now?: number; resumeToken?: string } = {},
|
||||
): PaymentRecoverySnapshot | null {
|
||||
if (!raw) return null
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<PaymentRecoverySnapshot>
|
||||
if (
|
||||
typeof parsed.orderId !== 'number'
|
||||
|| typeof parsed.amount !== 'number'
|
||||
|| typeof parsed.qrCode !== 'string'
|
||||
|| typeof parsed.expiresAt !== 'string'
|
||||
|| typeof parsed.paymentType !== 'string'
|
||||
|| typeof parsed.payUrl !== 'string'
|
||||
|| typeof parsed.clientSecret !== 'string'
|
||||
|| typeof parsed.payAmount !== 'number'
|
||||
|| typeof parsed.paymentMode !== 'string'
|
||||
|| typeof parsed.resumeToken !== 'string'
|
||||
|| typeof parsed.createdAt !== 'number'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const now = options.now ?? Date.now()
|
||||
const expiresAt = Date.parse(parsed.expiresAt)
|
||||
if (Number.isFinite(expiresAt) && expiresAt <= now) {
|
||||
return null
|
||||
}
|
||||
if (options.resumeToken && parsed.resumeToken && parsed.resumeToken !== options.resumeToken) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
orderId: parsed.orderId,
|
||||
amount: parsed.amount,
|
||||
qrCode: parsed.qrCode,
|
||||
expiresAt: parsed.expiresAt,
|
||||
paymentType: parsed.paymentType,
|
||||
payUrl: parsed.payUrl,
|
||||
clientSecret: parsed.clientSecret,
|
||||
payAmount: parsed.payAmount,
|
||||
orderType: parsed.orderType === 'subscription' ? 'subscription' : 'balance',
|
||||
paymentMode: parsed.paymentMode,
|
||||
resumeToken: parsed.resumeToken,
|
||||
createdAt: parsed.createdAt,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user