From 0838e0e6107fc7fb5b64d4ba9a4456120e8031eb Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Wed, 16 Sep 2026 18:17:29 +0800 Subject: [PATCH] fix(admin): reject negative platform quota limits before saving --- .../admin/user/UserPlatformQuotaModal.vue | 8 +++---- .../__tests__/UserPlatformQuotaModal.spec.ts | 24 ++++++++++++++++++- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/admin/user/UserPlatformQuotaModal.vue b/frontend/src/components/admin/user/UserPlatformQuotaModal.vue index 536f9a8424..57cb4b394d 100644 --- a/frontend/src/components/admin/user/UserPlatformQuotaModal.vue +++ b/frontend/src/components/admin/user/UserPlatformQuotaModal.vue @@ -231,14 +231,12 @@ function onClearAll() { async function onSave() { if (!props.user) return - // 校验所有 input:v-model.number 在用户输入"0."等中间状态时会写回 NaN, - // 之前的 normalizeLimit(NaN) 静默返回 null("无限制"),把"有限额"配置悄悄改成"无限制"。 - // 这里在 save 前显式检测 NaN,提示用户修正后再提交。 + // 拒绝非法数值,避免 normalizeLimit 将其静默转换为 null(无限额)。 const invalid: string[] = [] for (const row of quotas.value) { for (const win of ['daily', 'weekly', 'monthly'] as const) { const v = row[`${win}_limit_usd` as const] - if (typeof v === 'number' && Number.isNaN(v)) { + if (typeof v === 'number' && (!Number.isFinite(v) || v < 0)) { invalid.push(`${row.platform}.${win}`) } } @@ -268,7 +266,7 @@ async function onSave() { } // 仅在合法输入下返回数字:null/undefined/NaN/±Inf/负数 → null(视为"无限额")。 -// 调用方负责在 NaN 路径上做单独的用户提示(见 onSave)。 +// 调用方在保存前拦截非法数值(见 onSave)。 function normalizeLimit(v: number | null | undefined): number | null { if (v === null || v === undefined) return null if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return v diff --git a/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts b/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts index d582126294..50740b7cef 100644 --- a/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts +++ b/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts @@ -3,6 +3,7 @@ import { mount, flushPromises } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' const apiMocks = vi.hoisted(() => ({ + showError: vi.fn(), getPlatformQuotas: vi.fn(), updatePlatformQuotas: vi.fn(), resetPlatformQuotaWindow: vi.fn(), @@ -20,7 +21,7 @@ vi.mock('@/api/admin', () => ({ vi.mock('@/stores/app', () => ({ useAppStore: () => ({ - showError: vi.fn(), + showError: apiMocks.showError, showSuccess: vi.fn(), }), })) @@ -74,6 +75,27 @@ beforeEach(() => { }) describe('UserPlatformQuotaModal', () => { + it.each([0, 4, 14])('does not turn a negative limit in input %s into unlimited', async (index) => { + const w = await mountAndOpen() + await w.findAll('input[type=number]')[index].setValue('-1') + await w.findAll('button').find(b => b.text() === 'admin.users.platformQuota.save')!.trigger('click') + await flushPromises() + expect(apiMocks.updatePlatformQuotas).not.toHaveBeenCalled() + expect(apiMocks.showError).toHaveBeenCalledWith('admin.users.platformQuota.invalidNumber') + expect(w.emitted('success')).toBeUndefined() + w.unmount() + }) + + it.each([['0', 0], ['', null]])('preserves the meaning of a limit entered as %j', async (input, expected) => { + const w = await mountAndOpen() + await w.findAll('input[type=number]')[0].setValue(input) + await w.findAll('button').find(b => b.text() === 'admin.users.platformQuota.save')!.trigger('click') + await flushPromises() + expect(apiMocks.updatePlatformQuotas).toHaveBeenCalledTimes(1) + expect(apiMocks.updatePlatformQuotas.mock.calls[0][1][0].daily_limit_usd).toBe(expected) + w.unmount() + }) + it('挂载并 show=true 时调用 getPlatformQuotas', async () => { await mountAndOpen() expect(apiMocks.getPlatformQuotas).toHaveBeenCalledWith(99)