mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 00:17:45 +08:00
fix(payment): restore recharge crediting and balance refresh
This commit is contained in:
@@ -132,6 +132,13 @@ export interface PaymentOrder {
|
||||
expires_at: string | null
|
||||
}
|
||||
|
||||
export interface WalletRechargeOrdersResponse extends WalletBalanceResponse {
|
||||
items: PaymentOrder[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface RefundRequest {
|
||||
id: string
|
||||
refund_no: string
|
||||
@@ -235,18 +242,8 @@ export const walletApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async listRechargeOrders(params?: { limit?: number; offset?: number }): Promise<{
|
||||
items: PaymentOrder[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}> {
|
||||
const response = await apiClient.get<{
|
||||
items: PaymentOrder[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}>('/api/wallet/recharge', { params })
|
||||
async listRechargeOrders(params?: { limit?: number; offset?: number }): Promise<WalletRechargeOrdersResponse> {
|
||||
const response = await apiClient.get<WalletRechargeOrdersResponse>('/api/wallet/recharge', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
|
||||
@@ -745,7 +745,7 @@ import {
|
||||
walletTransactionReasonLabel,
|
||||
} from '@/utils/walletDisplay'
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const { success, info, error: showError } = useToast()
|
||||
|
||||
const ENABLE_WALLET_ACTION_FORMS = true
|
||||
|
||||
@@ -787,6 +787,10 @@ const loadedTabs = new Set<string>()
|
||||
const tabLoadPromises = new Map<string, Promise<void>>()
|
||||
let refundEligibilityLoaded = false
|
||||
let todayCostPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let orderPollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let orderLoadVersion = 0
|
||||
let unmounted = false
|
||||
const pendingOrders = new Map<string, PaymentOrder>()
|
||||
|
||||
const rechargeForm = reactive({
|
||||
amount_usd: 10,
|
||||
@@ -922,6 +926,8 @@ onMounted(async () => {
|
||||
loadTransactions(),
|
||||
loadRechargeOptions(),
|
||||
])
|
||||
if (unmounted) return
|
||||
await loadOrders()
|
||||
syncTodayCostPolling()
|
||||
} finally {
|
||||
loadingInitial.value = false
|
||||
@@ -929,7 +935,10 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true
|
||||
orderLoadVersion += 1
|
||||
stopTodayCostPolling()
|
||||
stopOrderPolling()
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
})
|
||||
|
||||
@@ -943,7 +952,8 @@ watch(refundableOrders, () => {
|
||||
})
|
||||
|
||||
async function refreshWallet() {
|
||||
await Promise.all([loadBalance(), loadOrders(), loadTransactions()])
|
||||
await loadOrders()
|
||||
await Promise.all([loadBalance(), loadTransactions()])
|
||||
}
|
||||
|
||||
async function loadBalance() {
|
||||
@@ -991,7 +1001,7 @@ async function loadTodayCost() {
|
||||
}
|
||||
|
||||
function syncTodayCostPolling() {
|
||||
if (activeTab.value === 'transactions' && !document.hidden) {
|
||||
if (!unmounted && activeTab.value === 'transactions' && !document.hidden) {
|
||||
startTodayCostPolling()
|
||||
} else {
|
||||
stopTodayCostPolling()
|
||||
@@ -1013,22 +1023,96 @@ function stopTodayCostPolling() {
|
||||
|
||||
function handleVisibilityChange() {
|
||||
syncTodayCostPolling()
|
||||
if (document.hidden) {
|
||||
stopOrderPolling()
|
||||
} else if (!loadingInitial.value) {
|
||||
void reloadOrders(true)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOrders() {
|
||||
loadingOrders.value = true
|
||||
function isAwaitingCredit(order: PaymentOrder): boolean {
|
||||
return order.status === 'paid'
|
||||
|| (order.status === 'pending'
|
||||
&& (!order.expires_at || new Date(order.expires_at).getTime() > Date.now()))
|
||||
}
|
||||
|
||||
function stopOrderPolling() {
|
||||
if (orderPollTimer === null) return
|
||||
clearTimeout(orderPollTimer)
|
||||
orderPollTimer = null
|
||||
}
|
||||
|
||||
function scheduleOrderPolling() {
|
||||
stopOrderPolling()
|
||||
if (unmounted || document.hidden || ![...pendingOrders.values()].some(isAwaitingCredit)) return
|
||||
orderPollTimer = setTimeout(() => {
|
||||
orderPollTimer = null
|
||||
void reloadOrders(true)
|
||||
}, 5_000)
|
||||
}
|
||||
|
||||
function loadOrders() {
|
||||
return reloadOrders(false)
|
||||
}
|
||||
|
||||
async function reloadOrders(silent: boolean) {
|
||||
if (unmounted) return
|
||||
stopOrderPolling()
|
||||
const version = ++orderLoadVersion
|
||||
loadingOrders.value = !silent
|
||||
try {
|
||||
const offset = (orderPage.value - 1) * orderPageSize.value
|
||||
const resp = await walletApi.listRechargeOrders({ limit: orderPageSize.value, offset })
|
||||
if (unmounted || version !== orderLoadVersion) return
|
||||
const orders = [...resp.items]
|
||||
const visibleIds = new Set(orders.map(order => order.id))
|
||||
const missingOrders = [...pendingOrders.values()].filter(order =>
|
||||
isAwaitingCredit(order) && !visibleIds.has(order.id)
|
||||
)
|
||||
const details = await Promise.allSettled(missingOrders.map(order => walletApi.getRechargeOrder(order.id)))
|
||||
if (unmounted || version !== orderLoadVersion) return
|
||||
for (const detail of details) {
|
||||
if (detail.status === 'fulfilled') orders.push(detail.value.order)
|
||||
else log.error('加载待到账订单失败:', detail.reason)
|
||||
}
|
||||
const newlyCredited = orders.filter(order =>
|
||||
order.status === 'credited' && pendingOrders.has(order.id)
|
||||
)
|
||||
for (const order of orders) {
|
||||
if (isAwaitingCredit(order)) pendingOrders.set(order.id, order)
|
||||
else if (order.status !== 'credited') pendingOrders.delete(order.id)
|
||||
if (latestRecharge.value?.order.id === order.id) latestRecharge.value.order = order
|
||||
}
|
||||
|
||||
// Order lists include the wallet snapshot, but omit the daily package quota.
|
||||
const { items: _items, total: _total, limit: _limit, offset: _offset, ...balance } = resp
|
||||
const currentPackageBalance = packageBalance.value
|
||||
const currentWalletBalance = Number(balance.wallet?.balance ?? balance.balance ?? 0)
|
||||
walletBalance.value = {
|
||||
...walletBalance.value,
|
||||
...balance,
|
||||
wallet_balance: Math.max(0, currentWalletBalance),
|
||||
total_available_balance: balance.unlimited ? null : Math.max(0, currentWalletBalance + currentPackageBalance),
|
||||
}
|
||||
rechargeOrders.value = resp.items
|
||||
orderTotal.value = resp.total
|
||||
loadedTabs.add('orders')
|
||||
syncRefundOrderSelection()
|
||||
if (newlyCredited.length > 0) {
|
||||
// Read after the credited status so a pre-credit list snapshot cannot win.
|
||||
await Promise.all([loadBalance(), loadTransactions()])
|
||||
for (const order of newlyCredited) pendingOrders.delete(order.id)
|
||||
if (!unmounted && version === orderLoadVersion) success('充值已到账,余额已更新')
|
||||
}
|
||||
} catch (error) {
|
||||
if (unmounted || version !== orderLoadVersion) return
|
||||
log.error('加载充值订单失败:', error)
|
||||
showError(parseApiError(error, '加载充值订单失败'))
|
||||
if (!silent) showError(parseApiError(error, '加载充值订单失败'))
|
||||
} finally {
|
||||
loadingOrders.value = false
|
||||
if (!unmounted && version === orderLoadVersion) {
|
||||
loadingOrders.value = false
|
||||
scheduleOrderPolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1164,8 +1248,11 @@ async function submitRecharge() {
|
||||
payment_channel: option.payment_channel,
|
||||
idempotency_key: rechargeForm.idempotency_key,
|
||||
})
|
||||
if (isAwaitingCredit(latestRecharge.value.order)) {
|
||||
pendingOrders.set(latestRecharge.value.order.id, latestRecharge.value.order)
|
||||
}
|
||||
success('充值订单创建成功')
|
||||
await Promise.all([loadOrders(), loadBalance()])
|
||||
await loadOrders()
|
||||
activeTab.value = 'orders'
|
||||
submitPaymentInstructions(latestRecharge.value.payment_instructions)
|
||||
rechargeForm.idempotency_key = ''
|
||||
@@ -1234,8 +1321,8 @@ function isSafariBrowser(): boolean {
|
||||
}
|
||||
|
||||
async function handleStripePaymentSuccess() {
|
||||
success('支付已完成,正在刷新钱包余额')
|
||||
await Promise.all([loadBalance(), loadOrders(), loadTransactions(), loadTodayCost()])
|
||||
info('支付已提交,正在等待充值到账')
|
||||
await loadOrders()
|
||||
activeTab.value = 'orders'
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, nextTick, type App } from 'vue'
|
||||
import type { PaymentOrder, WalletBalanceResponse } from '@/api/wallet'
|
||||
import WalletCenter from '../WalletCenter.vue'
|
||||
|
||||
const walletApiMock = vi.hoisted(() => ({
|
||||
getBalance: vi.fn(),
|
||||
getFlow: vi.fn(),
|
||||
getTodayCost: vi.fn(),
|
||||
listRechargeOptions: vi.fn(),
|
||||
listRechargeOrders: vi.fn(),
|
||||
getRechargeOrder: vi.fn(),
|
||||
createRechargeOrder: vi.fn(),
|
||||
}))
|
||||
const toastMock = vi.hoisted(() => ({ success: vi.fn(), info: vi.fn(), error: vi.fn() }))
|
||||
|
||||
vi.mock('@/api/wallet', () => ({ walletApi: walletApiMock }))
|
||||
vi.mock('@/composables/useToast', () => ({ useToast: () => toastMock }))
|
||||
vi.mock('@/utils/logger', () => ({ log: { error: vi.fn() } }))
|
||||
vi.mock('@/components/ui', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
const passthrough = defineComponent({ setup: (_, { slots }) => () => h('div', slots.default?.()) })
|
||||
const button = defineComponent({ setup: (_, { slots }) => () => h('button', slots.default?.()) })
|
||||
return {
|
||||
...Object.fromEntries([
|
||||
'Badge', 'Card', 'Input', 'Label', 'Select', 'SelectContent', 'SelectItem',
|
||||
'SelectTrigger', 'SelectValue', 'Table', 'TableBody', 'TableCell', 'TableHead',
|
||||
'TableHeader', 'TableRow', 'Tabs', 'TabsContent', 'TabsList', 'TabsTrigger', 'Textarea',
|
||||
].map(name => [name, passthrough])),
|
||||
Button: button,
|
||||
RefreshButton: defineComponent({ setup: () => () => h('button', { 'data-refresh': true }, '刷新') }),
|
||||
Pagination: defineComponent({
|
||||
props: { current: Number },
|
||||
emits: ['update:current'],
|
||||
setup: (_, { emit }) => () => h('button', {
|
||||
'data-next-page': true,
|
||||
onClick: () => emit('update:current', 2),
|
||||
}, '下一页'),
|
||||
}),
|
||||
}
|
||||
})
|
||||
vi.mock('@/components/common', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
const empty = defineComponent({ setup: () => () => h('div') })
|
||||
return {
|
||||
EmptyState: empty,
|
||||
LoadingState: empty,
|
||||
StripePaymentDialog: defineComponent({
|
||||
emits: ['success'],
|
||||
setup: (_, { emit }) => () => h('button', {
|
||||
'data-stripe-success': true,
|
||||
onClick: () => emit('success', { intentId: 'pi-1', status: 'processing' }),
|
||||
}, 'Stripe 提交'),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const mountedApps: Array<{ app: App; root: HTMLElement }> = []
|
||||
let hidden = false
|
||||
|
||||
function walletBalance(amount: number): WalletBalanceResponse {
|
||||
return {
|
||||
wallet: {
|
||||
id: 'wallet-1', balance: amount, recharge_balance: amount, gift_balance: 0,
|
||||
refundable_balance: amount, currency: 'USD', status: 'active', total_recharged: amount,
|
||||
total_consumed: 0, total_refunded: 0, total_adjusted: 0, updated_at: '2026-09-11T00:00:00Z',
|
||||
},
|
||||
balance: amount, unlimited: false, limit_mode: 'finite', currency: 'USD',
|
||||
wallet_balance: amount, package_balance: 3, total_available_balance: amount + 3,
|
||||
daily_quota: { has_active: true, total_usd: 5, used_usd: 2, remaining_usd: 3, allow_wallet_overage: true },
|
||||
}
|
||||
}
|
||||
|
||||
function paymentOrder(status = 'pending', overrides: Partial<PaymentOrder> = {}): PaymentOrder {
|
||||
return {
|
||||
id: 'order-1', order_no: 'RECHARGE-1', wallet_id: 'wallet-1', user_id: 'user-1',
|
||||
amount_usd: 10, pay_amount: 10, pay_currency: 'USD', exchange_rate: 1,
|
||||
refunded_amount_usd: 0, refundable_amount_usd: status === 'credited' ? 10 : 0,
|
||||
payment_method: 'epay', gateway_order_id: 'gateway-1', gateway_response: null,
|
||||
status, created_at: '2026-09-11T00:00:00Z', paid_at: null,
|
||||
credited_at: status === 'credited' ? '2026-09-11T00:01:00Z' : null, expires_at: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function orderResponse(items: PaymentOrder[], amount = 2, offset = 0) {
|
||||
const { wallet_balance: _wallet, package_balance: _package, total_available_balance: _total, daily_quota: _quota, ...balance } = walletBalance(amount)
|
||||
return { ...balance, items, total: items.length, limit: 20, offset }
|
||||
}
|
||||
|
||||
async function flushPromises() {
|
||||
for (let i = 0; i < 10; i += 1) await Promise.resolve()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
async function mountWallet() {
|
||||
const root = document.createElement('div')
|
||||
document.body.append(root)
|
||||
const app = createApp(WalletCenter)
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
await flushPromises()
|
||||
return { app, root }
|
||||
}
|
||||
|
||||
function setHidden(value: boolean) {
|
||||
hidden = value
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.resetAllMocks()
|
||||
hidden = false
|
||||
vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden)
|
||||
walletApiMock.getBalance.mockResolvedValue(walletBalance(2))
|
||||
walletApiMock.getFlow.mockResolvedValue({ items: [], total: 0, today_entry: null })
|
||||
walletApiMock.getTodayCost.mockResolvedValue(null)
|
||||
walletApiMock.listRechargeOptions.mockResolvedValue({ items: [] })
|
||||
walletApiMock.listRechargeOrders.mockResolvedValue(orderResponse([paymentOrder()]))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('WalletCenter recharge synchronization', () => {
|
||||
it('waits for server credit after Stripe submission and refreshes the credited balance and flow', async () => {
|
||||
const { root } = await mountWallet()
|
||||
root.querySelector<HTMLButtonElement>('[data-stripe-success]')!.click()
|
||||
await flushPromises()
|
||||
expect(toastMock.info).toHaveBeenCalledWith('支付已提交,正在等待充值到账')
|
||||
expect(toastMock.success).not.toHaveBeenCalled()
|
||||
|
||||
walletApiMock.listRechargeOrders.mockResolvedValue(orderResponse([paymentOrder('paid')]))
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
expect(toastMock.success).not.toHaveBeenCalled()
|
||||
|
||||
// The order query can observe the credit after its wallet snapshot was read.
|
||||
walletApiMock.listRechargeOrders.mockResolvedValue(orderResponse([paymentOrder('credited')], 2))
|
||||
walletApiMock.getBalance.mockResolvedValue(walletBalance(12))
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
expect(root.textContent).toContain('$12.00')
|
||||
expect(root.textContent).toContain('$15.00')
|
||||
expect(walletApiMock.getFlow).toHaveBeenCalledTimes(2)
|
||||
expect(toastMock.success).toHaveBeenCalledWith('充值已到账,余额已更新')
|
||||
|
||||
const calls = walletApiMock.listRechargeOrders.mock.calls.length
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
expect(walletApiMock.listRechargeOrders).toHaveBeenCalledTimes(calls)
|
||||
})
|
||||
|
||||
it('updates the wallet and preserves package quota when the orders refresh button is clicked', async () => {
|
||||
walletApiMock.listRechargeOrders.mockResolvedValue(orderResponse([paymentOrder('credited')], 2))
|
||||
const { root } = await mountWallet()
|
||||
walletApiMock.listRechargeOrders.mockResolvedValue(orderResponse([paymentOrder('credited')], 12))
|
||||
|
||||
root.querySelectorAll<HTMLButtonElement>('[data-refresh]')[4]!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(root.textContent).toContain('$12.00')
|
||||
expect(root.textContent).toContain('$15.00')
|
||||
expect(root.textContent).toContain('已用 $2.00 / 每日 $5.00')
|
||||
expect(walletApiMock.getBalance).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('pauses polling while hidden and checks credit as soon as the page becomes visible', async () => {
|
||||
const { root } = await mountWallet()
|
||||
setHidden(true)
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
expect(walletApiMock.listRechargeOrders).toHaveBeenCalledTimes(1)
|
||||
|
||||
walletApiMock.listRechargeOrders.mockResolvedValue(orderResponse([paymentOrder('credited')], 12))
|
||||
walletApiMock.getBalance.mockResolvedValue(walletBalance(12))
|
||||
setHidden(false)
|
||||
await flushPromises()
|
||||
|
||||
expect(walletApiMock.listRechargeOrders).toHaveBeenCalledTimes(2)
|
||||
expect(root.textContent).toContain('$12.00')
|
||||
expect(walletApiMock.getFlow).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps tracking a pending recharge after the user changes the order page', async () => {
|
||||
const { root } = await mountWallet()
|
||||
walletApiMock.listRechargeOrders.mockResolvedValue(orderResponse([], 2, 20))
|
||||
walletApiMock.getRechargeOrder.mockResolvedValue({ order: paymentOrder('credited') })
|
||||
walletApiMock.getBalance.mockResolvedValue(walletBalance(12))
|
||||
root.querySelectorAll<HTMLButtonElement>('[data-next-page]')[1]!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(walletApiMock.getRechargeOrder).toHaveBeenCalledWith('order-1')
|
||||
expect(root.textContent).toContain('$12.00')
|
||||
expect(toastMock.success).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retries a temporary polling failure without showing an error toast', async () => {
|
||||
const { root } = await mountWallet()
|
||||
walletApiMock.listRechargeOrders.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
expect(toastMock.error).not.toHaveBeenCalled()
|
||||
|
||||
walletApiMock.listRechargeOrders.mockResolvedValue(orderResponse([paymentOrder('credited')], 12))
|
||||
walletApiMock.getBalance.mockResolvedValue(walletBalance(12))
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
expect(root.textContent).toContain('$12.00')
|
||||
expect(toastMock.success).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not restart polling when an in-flight response completes after unmount', async () => {
|
||||
const { app } = await mountWallet()
|
||||
let resolve!: (value: ReturnType<typeof orderResponse>) => void
|
||||
walletApiMock.listRechargeOrders.mockReturnValue(new Promise(complete => { resolve = complete }))
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
app.unmount()
|
||||
mountedApps.splice(0).forEach(({ root }) => root.remove())
|
||||
resolve(orderResponse([paymentOrder()]))
|
||||
await flushPromises()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user