mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 00:21:34 +00:00
Merge pull request #5183 from rick147/codex/feat-openai-reset-credit-cache
feat(openai): refresh reset credit state after quota reset
This commit is contained in:
@@ -834,21 +834,51 @@ export interface OpenAIQuotaResetResult {
|
||||
code: string
|
||||
credit?: OpenAIQuotaResetCredit | null
|
||||
windows_reset: number
|
||||
quota?: OpenAIQuotaUsage | null
|
||||
account?: Account | null
|
||||
cache_refreshed: boolean
|
||||
account_state_recovered: boolean
|
||||
warning_code?:
|
||||
| 'reset_credit_cache_refresh_failed'
|
||||
| 'account_state_recovery_failed'
|
||||
| 'account_state_refresh_failed'
|
||||
}
|
||||
|
||||
/** Usage payload plus whether the reset-credit snapshot was persisted. */
|
||||
export interface OpenAIQuotaRefreshResult extends OpenAIQuotaUsage {
|
||||
cache_persisted: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Query OpenAI/Codex rate-limit usage for an OAuth account.
|
||||
* Query the upstream quota AND persist the reset-credit snapshot on the account
|
||||
* so the card can be rehydrated without an upstream round-trip. It is a POST
|
||||
* because it writes account state (and must therefore be audited).
|
||||
*
|
||||
* The read-only `GET /admin/openai/accounts/:id/quota` endpoint still exists for
|
||||
* API consumers; the panel always wants the snapshot persisted, so it has no
|
||||
* client binding here.
|
||||
*/
|
||||
export async function queryOpenAIQuota(id: number): Promise<OpenAIQuotaUsage> {
|
||||
const { data } = await apiClient.get<OpenAIQuotaUsage>(`/admin/openai/accounts/${id}/quota`)
|
||||
export async function refreshOpenAIQuota(id: number): Promise<OpenAIQuotaRefreshResult> {
|
||||
const { data } = await apiClient.post<OpenAIQuotaRefreshResult>(
|
||||
`/admin/openai/accounts/${id}/quota/refresh`
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume one rate-limit-reset credit for an OpenAI/Codex OAuth account.
|
||||
*
|
||||
* The credit is non-refundable and the endpoint chains an upstream reset with an
|
||||
* upstream re-query, so it needs a larger budget than the default client
|
||||
* timeout: aborting locally would report a successful consumption as a failure
|
||||
* and invite a retry that spends a second credit.
|
||||
*/
|
||||
export async function resetOpenAIQuota(id: number): Promise<OpenAIQuotaResetResult> {
|
||||
const { data } = await apiClient.post<OpenAIQuotaResetResult>(`/admin/openai/accounts/${id}/reset-quota`)
|
||||
const { data } = await apiClient.post<OpenAIQuotaResetResult>(
|
||||
`/admin/openai/accounts/${id}/reset-quota`,
|
||||
undefined,
|
||||
{ timeout: 90_000 }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -985,7 +1015,7 @@ export const accountsAPI = {
|
||||
batchRefresh,
|
||||
setPrivacy,
|
||||
revertProxyFallback,
|
||||
queryOpenAIQuota,
|
||||
refreshOpenAIQuota,
|
||||
resetOpenAIQuota,
|
||||
createSparkShadow,
|
||||
getUpstreamBillingProbeSettings,
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
refresh button is rendered via the pre-actions slot so the user sees a
|
||||
single row of related buttons instead of two stacked rows.
|
||||
-->
|
||||
<OpenAIQuotaResetCell :account="account">
|
||||
<OpenAIQuotaResetCell :account="account" @account-updated="handleQuotaResetAccountUpdated">
|
||||
<template #pre-actions>
|
||||
<button
|
||||
type="button"
|
||||
@@ -186,7 +186,11 @@
|
||||
<div v-else>
|
||||
<div class="text-xs text-gray-400">-</div>
|
||||
<!-- Always allow on-demand upstream quota query, even before local data exists. -->
|
||||
<OpenAIQuotaResetCell :account="account" class="mt-1" />
|
||||
<OpenAIQuotaResetCell
|
||||
:account="account"
|
||||
class="mt-1"
|
||||
@account-updated="handleQuotaResetAccountUpdated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -639,6 +643,8 @@ import OllamaCloudUsageCell from './OllamaCloudUsageCell.vue'
|
||||
// Module-level cache shared across all AccountUsageCell instances
|
||||
const _usageCache = new Map<number, { data: AccountUsageInfo; ts: number }>()
|
||||
const USAGE_CACHE_TTL = 5 * 60 * 1000 // 5 minutes
|
||||
// How long a quota-reset response may suppress the row-patch usage refetch.
|
||||
const SUPPRESS_USAGE_REFRESH_WINDOW_MS = 5 * 1000
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -654,6 +660,10 @@ const props = withDefaults(
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'account-updated': [account: Account]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const desktopViewportQuery = '(min-width: 768px)'
|
||||
|
||||
@@ -664,6 +674,7 @@ const loading = ref(false)
|
||||
const activeQueryLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const usageInfo = ref<AccountUsageInfo | null>(null)
|
||||
const suppressOpenAIUsageRefreshUntil = ref(0)
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const isDesktopViewport = ref(
|
||||
typeof window === 'undefined' ? true : window.matchMedia(desktopViewportQuery).matches
|
||||
@@ -1473,6 +1484,15 @@ const quotaTotalBar = computed((): QuotaBarInfo | null => {
|
||||
return makeQuotaBar(props.account.quota_used ?? 0, limit)
|
||||
})
|
||||
|
||||
const handleQuotaResetAccountUpdated = (account: Account) => {
|
||||
// The reset response already carries authoritative quota and account data.
|
||||
// Avoid turning the parent patch into a second automatic /usage request.
|
||||
// The suppression is time-boxed so an unhandled emit (parent that ignores
|
||||
// account-updated) cannot latch it and swallow a later, unrelated refresh.
|
||||
suppressOpenAIUsageRefreshUntil.value = Date.now() + SUPPRESS_USAGE_REFRESH_WINDOW_MS
|
||||
emit('account-updated', account)
|
||||
}
|
||||
|
||||
// ===== Key account today stats formatters =====
|
||||
|
||||
const formatKeyRequests = computed(() => {
|
||||
@@ -1517,6 +1537,10 @@ onMounted(() => {
|
||||
watch(openAIUsageRefreshKey, (nextKey, prevKey) => {
|
||||
if (!prevKey || nextKey === prevKey) return
|
||||
if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return
|
||||
if (Date.now() < suppressOpenAIUsageRefreshUntil.value) {
|
||||
suppressOpenAIUsageRefreshUntil.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
_usageCache.delete(props.account.id)
|
||||
requestAutoLoad()
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
class="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium text-blue-600 transition-colors hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-blue-400 dark:hover:bg-blue-900/30"
|
||||
:disabled="loading || resetting"
|
||||
:title="countButtonTitle"
|
||||
@click="handleQuery"
|
||||
@click="handleQuery()"
|
||||
>
|
||||
<svg
|
||||
class="h-2.5 w-2.5"
|
||||
@@ -111,6 +111,12 @@
|
||||
>
|
||||
{{ truncatedError }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="resetWarning"
|
||||
class="text-[10px] text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
{{ resetWarning }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="resetMessage"
|
||||
class="text-[10px] text-emerald-600 dark:text-emerald-400"
|
||||
@@ -136,7 +142,7 @@ import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Account } from '@/types'
|
||||
import {
|
||||
queryOpenAIQuota,
|
||||
refreshOpenAIQuota,
|
||||
resetOpenAIQuota,
|
||||
type OpenAIQuotaUsage,
|
||||
type OpenAIQuotaResetResult
|
||||
@@ -147,6 +153,10 @@ const props = defineProps<{
|
||||
account: Account
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'account-updated': [account: Account]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Visible only for OpenAI OAuth accounts.
|
||||
@@ -156,17 +166,67 @@ const loading = ref(false)
|
||||
const resetting = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const data = ref<OpenAIQuotaUsage | null>(null)
|
||||
const cachedData = ref<OpenAIQuotaUsage | null>(null)
|
||||
const resetMessage = ref<string | null>(null)
|
||||
const resetWarning = ref<string | null>(null)
|
||||
const showResetConfirm = ref(false)
|
||||
const showResetCreditDetails = ref(false)
|
||||
|
||||
// Rehydrate the card from the persisted snapshot. Credits that already expired
|
||||
// are dropped and the count is clamped to what remains: the snapshot has no
|
||||
// freshness signal, so an unfiltered read would offer to consume credits that no
|
||||
// longer exist. A snapshot claiming credits with no usable expiration left is
|
||||
// treated as absent, which keeps the reset button gated on a live query.
|
||||
const readCachedResetCredits = (account: Account): OpenAIQuotaUsage | null => {
|
||||
const cached = account.extra?.codex_reset_credit_snapshot
|
||||
if (!cached || typeof cached !== 'object' || Array.isArray(cached)) return null
|
||||
|
||||
const { available_count: count, credits: rawCredits } = cached as {
|
||||
available_count?: unknown
|
||||
credits?: unknown
|
||||
}
|
||||
if (typeof count !== 'number' || !Number.isFinite(count)) return null
|
||||
|
||||
const now = Date.now()
|
||||
const credits: { expires_at?: string }[] = []
|
||||
if (Array.isArray(rawCredits)) {
|
||||
for (const credit of rawCredits) {
|
||||
if (!credit || typeof credit !== 'object') continue
|
||||
const expiresAt = (credit as { expires_at?: unknown }).expires_at
|
||||
if (typeof expiresAt !== 'string' || expiresAt.trim() === '') continue
|
||||
const expiryTime = new Date(expiresAt).getTime()
|
||||
// Unparsable timestamps are kept: they are already rendered verbatim and
|
||||
// dropping them would silently understate the available count.
|
||||
if (!Number.isNaN(expiryTime) && expiryTime <= now) continue
|
||||
credits.push({ expires_at: expiresAt })
|
||||
}
|
||||
}
|
||||
const availableCount = Math.min(Math.max(count, 0), credits.length)
|
||||
// A snapshot that claimed credits but has none left is no longer informative;
|
||||
// report "unknown" so the operator re-queries instead of trusting it.
|
||||
if (count > 0 && availableCount <= 0) return null
|
||||
return {
|
||||
fetched_at: 0,
|
||||
rate_limit_reset_credits: {
|
||||
available_count: availableCount,
|
||||
credits
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cachedData.value = readCachedResetCredits(props.account)
|
||||
data.value = cachedData.value
|
||||
|
||||
// 影子账号的额度查询会 resolve 到母账号,但影子本身不支持重置(后端返回 409);
|
||||
// 重置必须在母账号上进行。前端据此禁用影子的重置入口(外审 F6)。
|
||||
const isShadow = computed(() => props.account.parent_account_id != null)
|
||||
|
||||
const availableResetCount = computed(() => data.value?.rate_limit_reset_credits?.available_count ?? 0)
|
||||
// Prefer the live payload and fall back to the persisted snapshot only when the
|
||||
// live state is unknown, so the count and the expirations never come from two
|
||||
// different generations of the same data.
|
||||
const resetCreditExpirations = computed(() =>
|
||||
(data.value?.rate_limit_reset_credits?.credits ?? [])
|
||||
((data.value ?? cachedData.value)?.rate_limit_reset_credits?.credits ?? [])
|
||||
.map((credit) => credit.expires_at?.trim() ?? '')
|
||||
.filter((expiresAt) => expiresAt.length > 0)
|
||||
.sort(compareResetCreditExpiry)
|
||||
@@ -265,9 +325,19 @@ const handleQuery = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
resetMessage.value = null
|
||||
resetWarning.value = null
|
||||
showResetCreditDetails.value = false
|
||||
try {
|
||||
data.value = await queryOpenAIQuota(props.account.id)
|
||||
const result = await refreshOpenAIQuota(props.account.id)
|
||||
// The upstream read succeeded even when the snapshot write was rejected, so
|
||||
// the live count is always adopted. Only the persisted view is left alone,
|
||||
// which keeps the displayed expirations consistent with what is stored.
|
||||
data.value = result
|
||||
if (result.cache_persisted) {
|
||||
cachedData.value = result
|
||||
} else {
|
||||
resetWarning.value = t('admin.accounts.openaiQuotaReset.refreshCachePersistFailed')
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = extractErrorMessage(e)
|
||||
} finally {
|
||||
@@ -294,15 +364,32 @@ const confirmReset = async () => {
|
||||
resetting.value = true
|
||||
error.value = null
|
||||
resetMessage.value = null
|
||||
resetWarning.value = null
|
||||
try {
|
||||
const result: OpenAIQuotaResetResult = await resetOpenAIQuota(props.account.id)
|
||||
// Refresh the reset-credit count so the badge reflects the consumed credit.
|
||||
// handleQuery clears resetMessage on entry, so the success toast is set
|
||||
// AFTER it resolves.
|
||||
await handleQuery()
|
||||
resetMessage.value = t('admin.accounts.openaiQuotaReset.resetSuccess', {
|
||||
windows: result.windows_reset
|
||||
})
|
||||
showResetCreditDetails.value = false
|
||||
if (result.cache_refreshed && result.quota) {
|
||||
data.value = result.quota
|
||||
cachedData.value = result.quota
|
||||
} else {
|
||||
// A credit was consumed but the post-reset count could not be read back.
|
||||
// Whatever we still hold is one generation stale, so report the count as
|
||||
// unknown instead of letting a second consumption start from stale data.
|
||||
data.value = null
|
||||
}
|
||||
if (result.account) emit('account-updated', result.account)
|
||||
|
||||
if (result.warning_code === 'reset_credit_cache_refresh_failed') {
|
||||
resetWarning.value = t('admin.accounts.openaiQuotaReset.resetCacheRefreshFailed')
|
||||
} else if (result.warning_code === 'account_state_recovery_failed') {
|
||||
resetWarning.value = t('admin.accounts.openaiQuotaReset.resetAccountRecoveryFailed')
|
||||
} else if (result.warning_code === 'account_state_refresh_failed') {
|
||||
resetWarning.value = t('admin.accounts.openaiQuotaReset.resetAccountRefreshFailed')
|
||||
} else {
|
||||
resetMessage.value = t('admin.accounts.openaiQuotaReset.resetSuccess', {
|
||||
windows: result.windows_reset
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = extractErrorMessage(e)
|
||||
} finally {
|
||||
@@ -314,9 +401,11 @@ watch(
|
||||
() => props.account.id,
|
||||
() => {
|
||||
// Account row may be reused across paginated lists; reset local state.
|
||||
data.value = null
|
||||
cachedData.value = readCachedResetCredits(props.account)
|
||||
data.value = cachedData.value
|
||||
error.value = null
|
||||
resetMessage.value = null
|
||||
resetWarning.value = null
|
||||
loading.value = false
|
||||
resetting.value = false
|
||||
showResetConfirm.value = false
|
||||
|
||||
@@ -512,6 +512,50 @@ describe('AccountUsageCell', () => {
|
||||
expect(wrapper.text()).toContain('5h|0|200')
|
||||
})
|
||||
|
||||
it('OpenAI 重置响应更新账号行时不会额外拉取 usage', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
five_hour: {
|
||||
utilization: 0,
|
||||
resets_at: null,
|
||||
remaining_seconds: 0
|
||||
},
|
||||
seven_day: null
|
||||
})
|
||||
const account = makeAccount({
|
||||
id: 2004,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
updated_at: '2026-03-07T10:00:00Z',
|
||||
extra: {}
|
||||
})
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: { account },
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: true,
|
||||
AccountQuotaInfo: true,
|
||||
OpenAIQuotaResetCell: {
|
||||
props: ['account'],
|
||||
emits: ['account-updated'],
|
||||
template: '<button data-test="quota-reset-result" @click="$emit(\'account-updated\', { ...account, updated_at: \'2026-03-07T10:01:00Z\' })" />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
expect(getUsage).toHaveBeenCalledTimes(1)
|
||||
|
||||
await wrapper.get('[data-test="quota-reset-result"]').trigger('click')
|
||||
const updatedAccount = wrapper.emitted<Account[]>('account-updated')?.[0]?.[0]
|
||||
expect(updatedAccount?.updated_at).toBe('2026-03-07T10:01:00Z')
|
||||
|
||||
await wrapper.setProps({ account: updatedAccount as Account })
|
||||
await flushPromises()
|
||||
|
||||
expect(getUsage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('OpenAI OAuth 已限额时显示 /usage API 返回的限额数据', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
five_hour: {
|
||||
|
||||
+213
-6
@@ -1,11 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import OpenAIQuotaResetCell from '../OpenAIQuotaResetCell.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import type { Account } from '@/types'
|
||||
import { queryOpenAIQuota } from '@/api/admin/accounts'
|
||||
import { refreshOpenAIQuota, resetOpenAIQuota } from '@/api/admin/accounts'
|
||||
|
||||
vi.mock('@/api/admin/accounts', () => ({
|
||||
queryOpenAIQuota: vi.fn(),
|
||||
refreshOpenAIQuota: vi.fn(),
|
||||
resetOpenAIQuota: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -20,6 +21,11 @@ vi.mock('vue-i18n', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// 缓存水合会丢弃已过期的重置卡,因此缓存类用例必须使用未来时间。
|
||||
const FUTURE_EXPIRY_EARLY = '2099-07-03T04:05:06Z'
|
||||
const FUTURE_EXPIRY_LATE = '2099-07-05T04:05:06Z'
|
||||
const PAST_EXPIRY = '2020-07-03T04:05:06Z'
|
||||
|
||||
function makeAccount(overrides: Partial<Account>): Account {
|
||||
return {
|
||||
id: 1,
|
||||
@@ -54,7 +60,8 @@ const resetButton = (wrapper: ReturnType<typeof mount>) =>
|
||||
wrapper.findAll('button')[1]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(queryOpenAIQuota).mockReset()
|
||||
vi.mocked(refreshOpenAIQuota).mockReset()
|
||||
vi.mocked(resetOpenAIQuota).mockReset()
|
||||
})
|
||||
|
||||
describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
|
||||
@@ -78,8 +85,71 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('从账号 extra 缓存恢复重置卡次数和到期时间', () => {
|
||||
const account = makeAccount({
|
||||
parent_account_id: null,
|
||||
extra: {
|
||||
codex_reset_credit_snapshot: {
|
||||
available_count: 2,
|
||||
credits: [
|
||||
{ expires_at: FUTURE_EXPIRY_LATE },
|
||||
{ expires_at: FUTURE_EXPIRY_EARLY },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
|
||||
|
||||
expect(refreshOpenAIQuota).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.count')
|
||||
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:')
|
||||
expect(wrapper.text()).toContain('+1')
|
||||
expect(resetButton(wrapper).attributes('disabled')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('缓存中的重置卡全部过期时视为未知,不点亮重置入口', () => {
|
||||
const account = makeAccount({
|
||||
parent_account_id: null,
|
||||
extra: {
|
||||
codex_reset_credit_snapshot: {
|
||||
available_count: 1,
|
||||
credits: [{ expires_at: PAST_EXPIRY }],
|
||||
},
|
||||
},
|
||||
})
|
||||
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
|
||||
|
||||
expect(wrapper.text()).not.toContain('admin.accounts.openaiQuotaReset.expiresAt:')
|
||||
const btn = resetButton(wrapper)
|
||||
expect(btn.attributes('disabled')).toBeDefined()
|
||||
expect(btn.attributes('title')).toBe('admin.accounts.openaiQuotaReset.resetTooltipNeedQuery')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('缓存次数向未过期的明细数量收敛', () => {
|
||||
const account = makeAccount({
|
||||
parent_account_id: null,
|
||||
extra: {
|
||||
codex_reset_credit_snapshot: {
|
||||
available_count: 3,
|
||||
credits: [
|
||||
{ expires_at: PAST_EXPIRY },
|
||||
{ expires_at: FUTURE_EXPIRY_EARLY },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
|
||||
|
||||
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.count1')
|
||||
expect(wrapper.text()).not.toContain('+1')
|
||||
expect(resetButton(wrapper).attributes('disabled')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('查询后默认折叠为最早到期时间,点击 +N 展开完整列表', async () => {
|
||||
vi.mocked(queryOpenAIQuota).mockResolvedValue({
|
||||
vi.mocked(refreshOpenAIQuota).mockResolvedValue({
|
||||
rate_limit_reset_credits: {
|
||||
available_count: 3,
|
||||
credits: [
|
||||
@@ -89,6 +159,7 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
|
||||
],
|
||||
},
|
||||
fetched_at: 1770000000,
|
||||
cache_persisted: true,
|
||||
})
|
||||
|
||||
const account = makeAccount({ parent_account_id: null })
|
||||
@@ -97,7 +168,7 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
|
||||
await wrapper.findAll('button')[0].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(queryOpenAIQuota).toHaveBeenCalledWith(1)
|
||||
expect(refreshOpenAIQuota).toHaveBeenCalledWith(1)
|
||||
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:')
|
||||
expect(wrapper.text()).toContain('+2')
|
||||
expect(wrapper.text()).not.toContain('not-a-date')
|
||||
@@ -115,7 +186,7 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
|
||||
})
|
||||
|
||||
it('只有一张重置卡时不显示展开按钮', async () => {
|
||||
vi.mocked(queryOpenAIQuota).mockResolvedValue({
|
||||
vi.mocked(refreshOpenAIQuota).mockResolvedValue({
|
||||
rate_limit_reset_credits: {
|
||||
available_count: 1,
|
||||
credits: [
|
||||
@@ -123,6 +194,7 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
|
||||
],
|
||||
},
|
||||
fetched_at: 1770000000,
|
||||
cache_persisted: true,
|
||||
})
|
||||
|
||||
const account = makeAccount({ parent_account_id: null })
|
||||
@@ -136,4 +208,139 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
|
||||
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// 快照写库被拒绝(上游未返回到期明细)不得吞掉这次成功的上游读取,
|
||||
// 否则次数永远显示不出来、重置入口被永久禁用。
|
||||
it('快照持久化失败时仍显示实时次数并给出警告', async () => {
|
||||
vi.mocked(refreshOpenAIQuota).mockResolvedValue({
|
||||
rate_limit_reset_credits: { available_count: 2 },
|
||||
fetched_at: 1770000000,
|
||||
cache_persisted: false,
|
||||
})
|
||||
|
||||
const account = makeAccount({ parent_account_id: null })
|
||||
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
|
||||
|
||||
await wrapper.findAll('button')[0].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.count2')
|
||||
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.refreshCachePersistFailed')
|
||||
expect(resetButton(wrapper).attributes('disabled')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('重置成功后直接使用响应中的最新缓存并回传恢复后的账号', async () => {
|
||||
const recoveredAccount = makeAccount({
|
||||
parent_account_id: null,
|
||||
status: 'active',
|
||||
error_message: null,
|
||||
})
|
||||
vi.mocked(resetOpenAIQuota).mockResolvedValue({
|
||||
code: 'success',
|
||||
windows_reset: 1,
|
||||
cache_refreshed: true,
|
||||
account_state_recovered: true,
|
||||
quota: {
|
||||
rate_limit_reset_credits: {
|
||||
available_count: 0,
|
||||
credits: [],
|
||||
},
|
||||
fetched_at: 1770000000,
|
||||
},
|
||||
account: recoveredAccount,
|
||||
})
|
||||
const account = makeAccount({
|
||||
parent_account_id: null,
|
||||
extra: {
|
||||
codex_reset_credit_snapshot: {
|
||||
available_count: 1,
|
||||
credits: [{ expires_at: FUTURE_EXPIRY_EARLY }],
|
||||
},
|
||||
},
|
||||
})
|
||||
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
|
||||
|
||||
await resetButton(wrapper).trigger('click')
|
||||
wrapper.findComponent(ConfirmDialog).vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
|
||||
expect(resetOpenAIQuota).toHaveBeenCalledWith(1)
|
||||
expect(refreshOpenAIQuota).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).not.toContain('admin.accounts.openaiQuotaReset.expiresAt:')
|
||||
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.resetSuccess')
|
||||
expect(wrapper.emitted('account-updated')).toEqual([[recoveredAccount]])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// 缓存回读失败不影响「账号状态已恢复」这一主目标:恢复后的账号行必须照常回传,
|
||||
// 否则列表会继续显示已经不存在的限流状态。
|
||||
it('缓存刷新失败时仍回传恢复后的账号并把次数标为未知', async () => {
|
||||
const recoveredAccount = makeAccount({
|
||||
parent_account_id: null,
|
||||
status: 'active',
|
||||
rate_limit_reset_at: null,
|
||||
})
|
||||
vi.mocked(resetOpenAIQuota).mockResolvedValue({
|
||||
code: 'success',
|
||||
windows_reset: 1,
|
||||
cache_refreshed: false,
|
||||
account_state_recovered: true,
|
||||
warning_code: 'reset_credit_cache_refresh_failed',
|
||||
account: recoveredAccount,
|
||||
})
|
||||
const account = makeAccount({
|
||||
parent_account_id: null,
|
||||
extra: {
|
||||
codex_reset_credit_snapshot: {
|
||||
available_count: 1,
|
||||
credits: [{ expires_at: FUTURE_EXPIRY_EARLY }],
|
||||
},
|
||||
},
|
||||
})
|
||||
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
|
||||
|
||||
await resetButton(wrapper).trigger('click')
|
||||
wrapper.findComponent(ConfirmDialog).vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
|
||||
expect(refreshOpenAIQuota).not.toHaveBeenCalled()
|
||||
// 次数未知(隐藏)但仍展示已持久化的到期明细,重置入口保持禁用直到重新查询。
|
||||
expect(wrapper.text()).not.toContain('admin.accounts.openaiQuotaReset.count1')
|
||||
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:')
|
||||
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.resetCacheRefreshFailed')
|
||||
expect(resetButton(wrapper).attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.emitted('account-updated')).toEqual([[recoveredAccount]])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('账号状态恢复失败时停止后续步骤并提示手动恢复', async () => {
|
||||
vi.mocked(resetOpenAIQuota).mockResolvedValue({
|
||||
code: 'success',
|
||||
windows_reset: 1,
|
||||
cache_refreshed: false,
|
||||
account_state_recovered: false,
|
||||
warning_code: 'account_state_recovery_failed',
|
||||
})
|
||||
const account = makeAccount({
|
||||
parent_account_id: null,
|
||||
extra: {
|
||||
codex_reset_credit_snapshot: {
|
||||
available_count: 1,
|
||||
credits: [{ expires_at: FUTURE_EXPIRY_EARLY }],
|
||||
},
|
||||
},
|
||||
})
|
||||
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
|
||||
|
||||
await resetButton(wrapper).trigger('click')
|
||||
wrapper.findComponent(ConfirmDialog).vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
|
||||
expect(refreshOpenAIQuota).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.resetAccountRecoveryFailed')
|
||||
expect(resetButton(wrapper).attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.emitted('account-updated')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1374,7 +1374,11 @@ export default {
|
||||
collapseExpirations: 'Collapse reset credit expirations',
|
||||
expirationDetails: 'Reset credit expiration details',
|
||||
noCreditsAvailable: 'No reset credits available',
|
||||
resetSuccess: 'Reset {windows} window(s)',
|
||||
resetSuccess: 'Reset {windows} window(s); credits and account state updated',
|
||||
resetCacheRefreshFailed: 'The window was reset and account state recovered, but the reset-credit count could not be read back. Query it again.',
|
||||
resetAccountRecoveryFailed: 'The window was reset, but account state recovery failed. Recover the account state manually.',
|
||||
resetAccountRefreshFailed: 'The window, account state, and reset-credit cache were updated, but the latest account display could not be loaded.',
|
||||
refreshCachePersistFailed: 'Showing the live count, but its expiration details were unavailable, so the cached details were kept.',
|
||||
confirmTitle: 'Confirm Weekly Limit Reset',
|
||||
confirmMessage: 'This will consume 1 reset credit to immediately restore the current window ({count} remaining). This action cannot be undone. Continue?'
|
||||
},
|
||||
|
||||
@@ -429,7 +429,11 @@ export default {
|
||||
collapseExpirations: '收起重置次数到期时间',
|
||||
expirationDetails: '重置次数到期明细',
|
||||
noCreditsAvailable: '没有可用的重置次数',
|
||||
resetSuccess: '已重置 {windows} 个窗口',
|
||||
resetSuccess: '已重置 {windows} 个窗口,次数和账号状态已更新',
|
||||
resetCacheRefreshFailed: '窗口已重置、账号状态已恢复,但重置次数未能回读,请重新查询次数。',
|
||||
resetAccountRecoveryFailed: '窗口已重置,但账号状态恢复失败,请手动恢复账号状态。',
|
||||
resetAccountRefreshFailed: '窗口、账号状态和重置次数缓存已更新,但无法加载最新账号显示。',
|
||||
refreshCachePersistFailed: '已显示实时次数,但到期明细获取失败,仍保留原有缓存明细。',
|
||||
confirmTitle: '确认重置周限',
|
||||
confirmMessage: '将消耗 1 次重置次数立即恢复当前窗口,剩余 {count} 次。此操作不可撤销,确定继续吗?'
|
||||
},
|
||||
|
||||
@@ -1070,6 +1070,10 @@ export interface Account {
|
||||
upstream_billing_probe_enabled?: boolean
|
||||
upstream_billing_rate_sync_enabled?: boolean
|
||||
upstream_billing_probe?: UpstreamBillingProbeSnapshot
|
||||
codex_reset_credit_snapshot?: {
|
||||
available_count?: number
|
||||
credits?: { expires_at?: string }[]
|
||||
}
|
||||
} & Record<string, unknown>)
|
||||
proxy_id: number | null
|
||||
proxy_fallback_origin_id?: number | null
|
||||
|
||||
@@ -318,6 +318,7 @@
|
||||
:today-stats="todayStatsByAccountId[String(row.id)] ?? null"
|
||||
:today-stats-loading="todayStatsLoading"
|
||||
:manual-refresh-token="usageManualRefreshToken"
|
||||
@account-updated="handleAccountUpdated"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-proxy="{ row }">
|
||||
|
||||
Reference in New Issue
Block a user