mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
fix: 徽章改读 grok_usage_snapshot,增量刷新比较 Grok 快照
后端写入 grok_usage_snapshot,列表却读无 writer 的 grok_quota_snapshot; 增量刷新不比 Grok 快照,档位更新后行不替换。 - 正式快照优先 usage,quota 仅兜底;计划字段只收非空字符串 - buildGrokUsageRefreshKey 接入自动刷新 - 注明网关 Extra 写入依赖每请求独立 Account 副本
This commit is contained in:
@@ -1306,6 +1306,9 @@ func (s *OpenAIGatewayService) updateGrokUsageSnapshot(ctx context.Context, acco
|
||||
stateCtx, cancel = openAIAccountStateContext(ctx)
|
||||
defer cancel()
|
||||
}
|
||||
// Account pointers on the request path are per-request copies (Redis/DB decode),
|
||||
// not a shared in-process cache. Mutating Extra here matches token refresh /
|
||||
// rate-limit writers; do not reuse the same *Account across goroutines.
|
||||
if account.Extra == nil {
|
||||
account.Extra = map[string]any{}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildOpenAIUsageRefreshKey } from '../accountUsageRefresh'
|
||||
import { buildGrokUsageRefreshKey, buildOpenAIUsageRefreshKey } from '../accountUsageRefresh'
|
||||
|
||||
describe('buildOpenAIUsageRefreshKey', () => {
|
||||
it('会在 codex 快照变化时生成不同 key', () => {
|
||||
@@ -61,3 +61,101 @@ describe('buildOpenAIUsageRefreshKey', () => {
|
||||
} as any)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildGrokUsageRefreshKey', () => {
|
||||
it('changes when a canonical Grok billing or usage snapshot changes', () => {
|
||||
const base = {
|
||||
platform: 'grok',
|
||||
extra: {
|
||||
grok_billing_snapshot: { plan: 'Free', usage_percent: 0 },
|
||||
grok_usage_snapshot: { subscription_tier: 'Free', status_code: 200 }
|
||||
}
|
||||
} as any
|
||||
|
||||
expect(buildGrokUsageRefreshKey(base)).not.toBe(buildGrokUsageRefreshKey({
|
||||
...base,
|
||||
extra: {
|
||||
...base.extra,
|
||||
grok_billing_snapshot: { plan: 'SuperGrok', usage_percent: 0 }
|
||||
}
|
||||
}))
|
||||
expect(buildGrokUsageRefreshKey(base)).not.toBe(buildGrokUsageRefreshKey({
|
||||
...base,
|
||||
extra: {
|
||||
...base.extra,
|
||||
grok_usage_snapshot: { subscription_tier: 'SuperGrok', status_code: 200 }
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
it('ignores object key order and a legacy alias shadowed by canonical usage', () => {
|
||||
const first = {
|
||||
platform: 'grok',
|
||||
extra: {
|
||||
grok_billing_snapshot: {
|
||||
plan: 'SuperGrok',
|
||||
limits: { monthly: 100, weekly: 25 }
|
||||
},
|
||||
grok_usage_snapshot: { status_code: 200, subscription_tier: 'SuperGrok' },
|
||||
grok_quota_snapshot: { subscription_tier: 'Free' }
|
||||
}
|
||||
} as any
|
||||
const reordered = {
|
||||
platform: 'grok',
|
||||
extra: {
|
||||
grok_quota_snapshot: { subscription_tier: 'SuperGrok Heavy' },
|
||||
grok_usage_snapshot: { subscription_tier: 'SuperGrok', status_code: 200 },
|
||||
grok_billing_snapshot: {
|
||||
limits: { weekly: 25, monthly: 100 },
|
||||
plan: 'SuperGrok'
|
||||
}
|
||||
}
|
||||
} as any
|
||||
|
||||
expect(buildGrokUsageRefreshKey(first)).toBe(buildGrokUsageRefreshKey(reordered))
|
||||
})
|
||||
|
||||
it('uses the legacy quota alias only when the canonical snapshot is absent', () => {
|
||||
const base = {
|
||||
platform: 'grok',
|
||||
extra: { grok_quota_snapshot: { subscription_tier: 'Free' } }
|
||||
} as any
|
||||
const next = {
|
||||
platform: 'grok',
|
||||
extra: { grok_quota_snapshot: { subscription_tier: 'SuperGrok' } }
|
||||
} as any
|
||||
|
||||
expect(buildGrokUsageRefreshKey(base)).not.toBe(buildGrokUsageRefreshKey(next))
|
||||
})
|
||||
|
||||
it('tracks the legacy tier when the canonical snapshot has no usable tier', () => {
|
||||
for (const canonicalSnapshot of [
|
||||
{ status_code: 200 },
|
||||
{ status_code: 200, subscription_tier: ' ' },
|
||||
]) {
|
||||
const base = {
|
||||
platform: 'grok',
|
||||
extra: {
|
||||
grok_usage_snapshot: canonicalSnapshot,
|
||||
grok_quota_snapshot: { subscription_tier: 'Free' },
|
||||
},
|
||||
} as any
|
||||
const next = {
|
||||
...base,
|
||||
extra: {
|
||||
...base.extra,
|
||||
grok_quota_snapshot: { subscription_tier: 'SuperGrok' },
|
||||
},
|
||||
}
|
||||
|
||||
expect(buildGrokUsageRefreshKey(base)).not.toBe(buildGrokUsageRefreshKey(next))
|
||||
}
|
||||
})
|
||||
|
||||
it('returns an empty key for non-Grok accounts', () => {
|
||||
expect(buildGrokUsageRefreshKey({
|
||||
platform: 'openai',
|
||||
extra: { grok_usage_snapshot: { subscription_tier: 'SuperGrok' } }
|
||||
} as any)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,30 @@ const normalizeUsageRefreshValue = (value: unknown): string => {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const normalizeSnapshotRefreshValue = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(normalizeSnapshotRefreshValue)
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, entry]) => entry !== undefined)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, entry]) => [key, normalizeSnapshotRefreshValue(entry)])
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const serializeSnapshotRefreshValue = (value: unknown): string => {
|
||||
if (value == null) return ''
|
||||
return JSON.stringify(normalizeSnapshotRefreshValue(value)) ?? ''
|
||||
}
|
||||
|
||||
const isNonBlankString = (value: unknown): value is string => (
|
||||
typeof value === 'string' && value.trim().length > 0
|
||||
)
|
||||
|
||||
export const buildOpenAIUsageRefreshKey = (account: Pick<Account, 'id' | 'platform' | 'type' | 'updated_at' | 'last_used_at' | 'rate_limit_reset_at' | 'extra'>): string => {
|
||||
if (account.platform !== 'openai' || account.type !== 'oauth') {
|
||||
return ''
|
||||
@@ -27,3 +51,21 @@ export const buildOpenAIUsageRefreshKey = (account: Pick<Account, 'id' | 'platfo
|
||||
extra.codex_7d_window_minutes
|
||||
].map(normalizeUsageRefreshValue).join('|')
|
||||
}
|
||||
|
||||
export const buildGrokUsageRefreshKey = (account: Pick<Account, 'platform' | 'extra'>): string => {
|
||||
if (account.platform !== 'grok') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const extra = account.extra ?? {}
|
||||
const usageSnapshot = extra.grok_usage_snapshot
|
||||
const canonicalTier = (usageSnapshot as Record<string, unknown> | null | undefined)?.subscription_tier
|
||||
const legacyQuotaFallback = isNonBlankString(canonicalTier)
|
||||
? undefined
|
||||
: extra.grok_quota_snapshot
|
||||
return [
|
||||
serializeSnapshotRefreshValue(extra.grok_billing_snapshot),
|
||||
serializeSnapshotRefreshValue(usageSnapshot),
|
||||
serializeSnapshotRefreshValue(legacyQuotaFallback)
|
||||
].join('|')
|
||||
}
|
||||
|
||||
@@ -525,7 +525,7 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
import ErrorPassthroughRulesModal from '@/components/admin/ErrorPassthroughRulesModal.vue'
|
||||
import TLSFingerprintProfilesModal from '@/components/admin/TLSFingerprintProfilesModal.vue'
|
||||
import { fetchAllAccountIds } from '@/utils/accountSelection'
|
||||
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
|
||||
import { buildGrokUsageRefreshKey, buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
|
||||
import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
||||
import { proxyExpiryBadgeClass, proxyExpiryLabelKey } from '@/utils/proxyExpiry'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
@@ -1300,7 +1300,8 @@ const shouldReplaceAutoRefreshRow = (current: Account, next: Account) => {
|
||||
current.rate_limit_reset_at !== next.rate_limit_reset_at ||
|
||||
current.overload_until !== next.overload_until ||
|
||||
current.temp_unschedulable_until !== next.temp_unschedulable_until ||
|
||||
buildOpenAIUsageRefreshKey(current) !== buildOpenAIUsageRefreshKey(next)
|
||||
buildOpenAIUsageRefreshKey(current) !== buildOpenAIUsageRefreshKey(next) ||
|
||||
buildGrokUsageRefreshKey(current) !== buildGrokUsageRefreshKey(next)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1484,13 +1485,32 @@ const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn(
|
||||
const GROK_QUOTA_SIGNAL_MAX_AGE_MS = 24 * 60 * 60 * 1000
|
||||
const GROK_QUOTA_SIGNAL_MAX_FUTURE_SKEW_MS = 5 * 60 * 1000
|
||||
|
||||
function firstNonBlankString(...values: unknown[]): string | undefined {
|
||||
return values.find((value): value is string => (
|
||||
typeof value === 'string' && value.trim().length > 0
|
||||
))
|
||||
}
|
||||
|
||||
function normalizeGrokPlanKey(value: unknown): string {
|
||||
return String(value ?? '')
|
||||
if (typeof value !== 'string') return ''
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_-]+/g, '')
|
||||
}
|
||||
|
||||
function grokPersistedQuotaSnapshot(extra: Record<string, any>): Record<string, any> | undefined {
|
||||
const usage = extra.grok_usage_snapshot
|
||||
if (usage && typeof usage === 'object' && !Array.isArray(usage)) {
|
||||
return usage as Record<string, any>
|
||||
}
|
||||
const legacy = extra.grok_quota_snapshot
|
||||
if (legacy && typeof legacy === 'object' && !Array.isArray(legacy)) {
|
||||
return legacy as Record<string, any>
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isGrokQuotaTimestampFresh(raw: unknown): boolean {
|
||||
const value = String(raw || '').trim()
|
||||
if (!value) return false
|
||||
@@ -1536,8 +1556,10 @@ function getAccountPlanType(row: any): string | undefined {
|
||||
if (row.platform === 'grok') {
|
||||
const extra = (row.extra || {}) as Record<string, any>
|
||||
const billing = extra.grok_billing_snapshot as Record<string, any> | undefined
|
||||
const quota = (extra.grok_quota_snapshot || extra.grok_usage_snapshot) as Record<string, any> | undefined
|
||||
const cred = row.credentials?.subscription_tier
|
||||
const usage = extra.grok_usage_snapshot as Record<string, any> | undefined
|
||||
const legacyQuota = extra.grok_quota_snapshot as Record<string, any> | undefined
|
||||
const quota = grokPersistedQuotaSnapshot(extra)
|
||||
const cred = firstNonBlankString(row.credentials?.subscription_tier)
|
||||
const credKey = normalizeGrokPlanKey(cred)
|
||||
if (credKey && credKey !== 'supergrokpro') {
|
||||
return cred
|
||||
@@ -1551,18 +1573,18 @@ function getAccountPlanType(row: any): string | undefined {
|
||||
return 'SuperGrok Heavy'
|
||||
}
|
||||
if (credKey === 'supergrokpro') {
|
||||
return billing?.plan || 'SuperGrok'
|
||||
return firstNonBlankString(billing?.plan) || 'SuperGrok'
|
||||
}
|
||||
return (
|
||||
billing?.plan ||
|
||||
quota?.subscription_tier ||
|
||||
extra.subscription_tier ||
|
||||
row.credentials?.plan_type ||
|
||||
row.parent_plan_type ||
|
||||
undefined
|
||||
return firstNonBlankString(
|
||||
billing?.plan,
|
||||
usage?.subscription_tier,
|
||||
legacyQuota?.subscription_tier,
|
||||
extra.subscription_tier,
|
||||
row.credentials?.plan_type,
|
||||
row.parent_plan_type
|
||||
)
|
||||
}
|
||||
return row.credentials?.plan_type || row.parent_plan_type || undefined
|
||||
return firstNonBlankString(row.credentials?.plan_type, row.parent_plan_type)
|
||||
}
|
||||
|
||||
function getOpenAIAuthMode(row: any): string | undefined {
|
||||
@@ -2418,6 +2440,7 @@ const handleClickOutside = (event: MouseEvent) => {
|
||||
|
||||
onMounted(async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
loadSavedAutoRefresh()
|
||||
desktopViewportMediaQuery = window.matchMedia(desktopViewportQuery)
|
||||
isDesktopViewport.value = desktopViewportMediaQuery.matches
|
||||
desktopViewportListener = (event: MediaQueryListEvent) => {
|
||||
|
||||
@@ -270,6 +270,8 @@ describe('admin AccountsView — 账号行展示', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
@@ -404,12 +406,18 @@ describe('admin AccountsView — 账号行展示', () => {
|
||||
credentials: { subscription_tier: 'SuperGrokPro' },
|
||||
extra: {
|
||||
grok_billing_snapshot: { plan: 'SuperGrok' },
|
||||
grok_quota_snapshot: {
|
||||
grok_usage_snapshot: {
|
||||
model: 'grok-4.5',
|
||||
last_headers_seen_at: new Date().toISOString(),
|
||||
requests: { limit: 8300 },
|
||||
tokens: { limit: 53_000_000 },
|
||||
},
|
||||
grok_quota_snapshot: {
|
||||
model: 'grok-4.6',
|
||||
last_headers_seen_at: new Date().toISOString(),
|
||||
requests: { limit: 8300 },
|
||||
tokens: { limit: 53_000_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -420,7 +428,7 @@ describe('admin AccountsView — 账号行展示', () => {
|
||||
credentials: { subscription_tier: 'SuperGrokPro' },
|
||||
extra: {
|
||||
grok_billing_snapshot: { plan: 'SuperGrok' },
|
||||
grok_quota_snapshot: {
|
||||
grok_usage_snapshot: {
|
||||
model: 'grok-4.6',
|
||||
last_headers_seen_at: new Date().toISOString(),
|
||||
requests: { limit: 8300 },
|
||||
@@ -428,6 +436,25 @@ describe('admin AccountsView — 账号行展示', () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 208,
|
||||
name: 'usage-over-legacy-quota',
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
credentials: {},
|
||||
extra: {
|
||||
grok_usage_snapshot: { subscription_tier: 'SuperGrok' },
|
||||
grok_quota_snapshot: { subscription_tier: 'Free' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 209,
|
||||
name: 'legacy-quota-alias',
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
credentials: {},
|
||||
extra: { grok_quota_snapshot: { subscription_tier: 'SuperGrok' } },
|
||||
},
|
||||
]
|
||||
|
||||
listAccounts.mockResolvedValue({
|
||||
@@ -450,8 +477,106 @@ describe('admin AccountsView — 账号行展示', () => {
|
||||
'SuperGrok',
|
||||
'SuperGrok Heavy',
|
||||
'SuperGrok',
|
||||
'SuperGrok',
|
||||
'SuperGrok',
|
||||
])
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('skips malformed Grok plan fields and safely uses the next valid fallback', async () => {
|
||||
const grokAccounts = [
|
||||
{
|
||||
id: 210,
|
||||
name: 'legacy-fallback',
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
credentials: {},
|
||||
extra: {
|
||||
grok_usage_snapshot: { subscription_tier: { name: 'SuperGrok Heavy' } },
|
||||
grok_quota_snapshot: { subscription_tier: 'SuperGrok' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 211,
|
||||
name: 'credential-plan-fallback',
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
credentials: { subscription_tier: 0, plan_type: 'SuperGrok Heavy' },
|
||||
extra: {
|
||||
grok_billing_snapshot: { plan: {} },
|
||||
grok_usage_snapshot: { subscription_tier: 1 },
|
||||
grok_quota_snapshot: { subscription_tier: [] },
|
||||
subscription_tier: ' ',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 212,
|
||||
name: 'no-valid-plan',
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
credentials: { subscription_tier: {}, plan_type: 2 },
|
||||
parent_plan_type: [],
|
||||
extra: {
|
||||
grok_billing_snapshot: { plan: [] },
|
||||
grok_usage_snapshot: { subscription_tier: 1 },
|
||||
grok_quota_snapshot: { subscription_tier: {} },
|
||||
subscription_tier: null,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
listAccounts.mockResolvedValue({
|
||||
items: grokAccounts,
|
||||
total: grokAccounts.length,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
pages: 1,
|
||||
})
|
||||
|
||||
const wrapper = mountViewWithRow()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAllComponents(PlatformTypeBadge).map((badge) => badge.props('planType'))).toEqual([
|
||||
'SuperGrok',
|
||||
'SuperGrok Heavy',
|
||||
undefined,
|
||||
])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('replaces a Grok row when auto refresh returns a changed canonical usage snapshot', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(document, 'hidden', 'get').mockReturnValue(false)
|
||||
localStorage.setItem('account-auto-refresh', JSON.stringify({ enabled: true, interval_seconds: 5 }))
|
||||
|
||||
const initialAccount = {
|
||||
id: 213,
|
||||
name: 'refresh-tier',
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
extra: { grok_usage_snapshot: { subscription_tier: 'Free', status_code: 200 } },
|
||||
}
|
||||
const refreshedAccount = {
|
||||
...initialAccount,
|
||||
extra: { grok_usage_snapshot: { subscription_tier: 'SuperGrok', status_code: 200 } },
|
||||
}
|
||||
listAccounts.mockResolvedValue({ items: [initialAccount], total: 1, page: 1, page_size: 20, pages: 1 })
|
||||
listWithEtag.mockResolvedValueOnce({
|
||||
notModified: false,
|
||||
etag: 'grok-snapshot-2',
|
||||
data: { items: [refreshedAccount], total: 1, page: 1, page_size: 20, pages: 1 },
|
||||
})
|
||||
|
||||
const wrapper = mountViewWithRow()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(PlatformTypeBadge).props('planType')).toBe('Free')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
await flushPromises()
|
||||
|
||||
expect(listWithEtag).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.findComponent(PlatformTypeBadge).props('planType')).toBe('SuperGrok')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user