feat: rebuild auth identity foundation flow

This commit is contained in:
IanShaw027
2026-04-20 17:39:57 +08:00
parent fbd0a2e3c4
commit e9de839d87
123 changed files with 33599 additions and 772 deletions

View File

@@ -0,0 +1,53 @@
<template>
<div class="space-y-4">
<button type="button" :disabled="disabled" class="btn btn-secondary w-full" @click="startLogin">
<span
class="mr-2 inline-flex h-5 w-5 items-center justify-center rounded-full bg-green-100 text-xs font-semibold text-green-700 dark:bg-green-900/30 dark:text-green-300"
>
W
</span>
{{ t('auth.oidc.signIn', { providerName }) }}
</button>
<div v-if="showDivider" class="flex items-center gap-3">
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
<span class="text-xs text-gray-500 dark:text-dark-400">
{{ t('auth.oauthOrContinue') }}
</span>
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
withDefaults(defineProps<{
disabled?: boolean
showDivider?: boolean
}>(), {
showDivider: true,
})
const route = useRoute()
const { t } = useI18n()
const providerName = 'WeChat'
function resolveWeChatOAuthMode(): 'open' | 'mp' {
if (typeof navigator === 'undefined') {
return 'open'
}
return /MicroMessenger/i.test(navigator.userAgent) ? 'mp' : 'open'
}
function startLogin(): void {
const redirectTo = (route.query.redirect as string) || '/dashboard'
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '')
const mode = resolveWeChatOAuthMode()
const startURL = `${normalized}/auth/oauth/wechat/start?mode=${mode}&redirect=${encodeURIComponent(redirectTo)}`
window.location.href = startURL
}
</script>

View File

@@ -0,0 +1,74 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
const routeState = vi.hoisted(() => ({
query: {} as Record<string, unknown>,
}))
const locationState = vi.hoisted(() => ({
current: { href: 'http://localhost/login' } as { href: string },
}))
vi.mock('vue-router', () => ({
useRoute: () => routeState,
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, string>) => {
if (key === 'auth.oidc.signIn') {
return `Continue with ${params?.providerName ?? ''}`.trim()
}
if (key === 'auth.oauthOrContinue') {
return 'or continue'
}
return key
},
}),
}))
describe('WechatOAuthSection', () => {
beforeEach(() => {
routeState.query = { redirect: '/billing?plan=pro' }
locationState.current = { href: 'http://localhost/login' }
Object.defineProperty(window, 'location', {
configurable: true,
value: locationState.current,
})
Object.defineProperty(window.navigator, 'userAgent', {
configurable: true,
value: 'Mozilla/5.0',
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('starts the open WeChat OAuth flow with the current redirect target', async () => {
const wrapper = mount(WechatOAuthSection)
expect(wrapper.text()).toContain('WeChat')
await wrapper.get('button').trigger('click')
expect(locationState.current.href).toContain(
'/api/v1/auth/oauth/wechat/start?mode=open&redirect=%2Fbilling%3Fplan%3Dpro'
)
})
it('uses mp mode inside the WeChat browser', async () => {
Object.defineProperty(window.navigator, 'userAgent', {
configurable: true,
value: 'Mozilla/5.0 MicroMessenger',
})
const wrapper = mount(WechatOAuthSection)
await wrapper.get('button').trigger('click')
expect(locationState.current.href).toContain(
'/api/v1/auth/oauth/wechat/start?mode=mp&redirect=%2Fbilling%3Fplan%3Dpro'
)
})
})

View File

@@ -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 {

View 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()
})
})

View 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
}
}