mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-05-05 13:40:44 +08:00
sync: bring over remaining release/custom-0.1.115 changes
- Extract PublicSettingsInjectionPayload named struct with drift test - Add channel_monitor_default_interval_seconds to SSR injection - Add image_output_price to SupportedModelChip - Simplify AppSidebar buildSelfNavItems (admins see available channels) - Add gateway WARN logs for 503 no-available-accounts branches - Wire ChannelMonitorRunner into provideCleanup for graceful shutdown - Add migrations 130/131 (CC template userid fix + mimicry field cleanup) - Clean up fork-only features (sora, claude max simulation, client affinity) - Remove ~320 obsolete i18n keys - Add codexUsage utility, WechatServiceButton, BulkEditAccountModal - Tidy go.sum
This commit is contained in:
186
frontend/src/utils/__tests__/codexUsage.spec.ts
Normal file
186
frontend/src/utils/__tests__/codexUsage.spec.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveCodexUsageWindow } from '@/utils/codexUsage'
|
||||
|
||||
describe('resolveCodexUsageWindow', () => {
|
||||
it('快照为空时返回空窗口', () => {
|
||||
const result = resolveCodexUsageWindow(null, '5h', new Date('2026-02-20T08:00:00Z'))
|
||||
expect(result).toEqual({ usedPercent: null, resetAt: null })
|
||||
})
|
||||
|
||||
it('优先使用后端提供的绝对重置时间', () => {
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_5h_used_percent: 55,
|
||||
codex_5h_reset_at: '2026-02-20T10:00:00Z',
|
||||
codex_5h_reset_after_seconds: 1
|
||||
},
|
||||
'5h',
|
||||
new Date('2026-02-20T08:00:00Z')
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(55)
|
||||
expect(result.resetAt).toBe('2026-02-20T10:00:00.000Z')
|
||||
})
|
||||
|
||||
it('窗口已过期时自动归零', () => {
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_7d_used_percent: 100,
|
||||
codex_7d_reset_at: '2026-02-20T07:00:00Z'
|
||||
},
|
||||
'7d',
|
||||
new Date('2026-02-20T08:00:00Z')
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(0)
|
||||
expect(result.resetAt).toBe('2026-02-20T07:00:00.000Z')
|
||||
})
|
||||
|
||||
it('无绝对时间时使用 updated_at + seconds 回退计算', () => {
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_5h_used_percent: 20,
|
||||
codex_5h_reset_after_seconds: 3600,
|
||||
codex_usage_updated_at: '2026-02-20T06:30:00Z'
|
||||
},
|
||||
'5h',
|
||||
new Date('2026-02-20T07:00:00Z')
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(20)
|
||||
expect(result.resetAt).toBe('2026-02-20T07:30:00.000Z')
|
||||
})
|
||||
|
||||
it('支持 legacy primary/secondary 字段映射', () => {
|
||||
const snapshot = {
|
||||
codex_primary_window_minutes: 10080,
|
||||
codex_primary_used_percent: 70,
|
||||
codex_primary_reset_after_seconds: 86400,
|
||||
codex_secondary_window_minutes: 300,
|
||||
codex_secondary_used_percent: 15,
|
||||
codex_secondary_reset_after_seconds: 1200,
|
||||
codex_usage_updated_at: '2026-02-20T07:00:00Z'
|
||||
}
|
||||
|
||||
const result5h = resolveCodexUsageWindow(snapshot, '5h', new Date('2026-02-20T07:05:00Z'))
|
||||
const result7d = resolveCodexUsageWindow(snapshot, '7d', new Date('2026-02-20T07:05:00Z'))
|
||||
|
||||
expect(result5h.usedPercent).toBe(15)
|
||||
expect(result5h.resetAt).toBe('2026-02-20T07:20:00.000Z')
|
||||
expect(result7d.usedPercent).toBe(70)
|
||||
expect(result7d.resetAt).toBe('2026-02-21T07:00:00.000Z')
|
||||
})
|
||||
|
||||
it('legacy 5h 在 primary<=360 时优先 primary 并支持字符串数字', () => {
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_primary_window_minutes: '300',
|
||||
codex_primary_used_percent: '21',
|
||||
codex_primary_reset_after_seconds: '1800',
|
||||
codex_secondary_window_minutes: '10080',
|
||||
codex_secondary_used_percent: '99',
|
||||
codex_secondary_reset_after_seconds: '99999',
|
||||
codex_usage_updated_at: '2026-02-20T08:00:00Z'
|
||||
},
|
||||
'5h',
|
||||
new Date('2026-02-20T08:10:00Z')
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(21)
|
||||
expect(result.resetAt).toBe('2026-02-20T08:30:00.000Z')
|
||||
})
|
||||
|
||||
it('legacy 5h 在无窗口信息时回退 secondary', () => {
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_secondary_used_percent: 19,
|
||||
codex_secondary_reset_after_seconds: 120,
|
||||
codex_usage_updated_at: '2026-02-20T08:00:00Z'
|
||||
},
|
||||
'5h',
|
||||
new Date('2026-02-20T08:00:01Z')
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(19)
|
||||
expect(result.resetAt).toBe('2026-02-20T08:02:00.000Z')
|
||||
})
|
||||
|
||||
it('legacy 场景下 secondary 为 7d 时能正确识别', () => {
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_primary_window_minutes: 300,
|
||||
codex_primary_used_percent: 5,
|
||||
codex_primary_reset_after_seconds: 600,
|
||||
codex_secondary_window_minutes: 10080,
|
||||
codex_secondary_used_percent: 66,
|
||||
codex_secondary_reset_after_seconds: 7200,
|
||||
codex_usage_updated_at: '2026-02-20T07:00:00Z'
|
||||
},
|
||||
'7d',
|
||||
new Date('2026-02-20T07:30:00Z')
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(66)
|
||||
expect(result.resetAt).toBe('2026-02-20T09:00:00.000Z')
|
||||
})
|
||||
|
||||
it('绝对时间非法时回退到 updated_at + seconds', () => {
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_5h_used_percent: 33,
|
||||
codex_5h_reset_at: 'not-a-date',
|
||||
codex_5h_reset_after_seconds: 900,
|
||||
codex_usage_updated_at: '2026-02-20T07:30:00Z'
|
||||
},
|
||||
'5h',
|
||||
new Date('2026-02-20T07:40:00Z')
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(33)
|
||||
expect(result.resetAt).toBe('2026-02-20T07:45:00.000Z')
|
||||
})
|
||||
|
||||
it('updated_at 非法且无绝对时间时 resetAt 返回 null', () => {
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_5h_used_percent: 10,
|
||||
codex_5h_reset_after_seconds: 123,
|
||||
codex_usage_updated_at: 'invalid-time'
|
||||
},
|
||||
'5h',
|
||||
new Date('2026-02-20T08:00:00Z')
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(10)
|
||||
expect(result.resetAt).toBeNull()
|
||||
})
|
||||
|
||||
it('reset_after_seconds 为负数时按 0 秒处理', () => {
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_5h_used_percent: 80,
|
||||
codex_5h_reset_after_seconds: -30,
|
||||
codex_usage_updated_at: '2026-02-20T08:00:00Z'
|
||||
},
|
||||
'5h',
|
||||
new Date('2026-02-20T07:59:00Z')
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(80)
|
||||
expect(result.resetAt).toBe('2026-02-20T08:00:00.000Z')
|
||||
})
|
||||
|
||||
it('百分比缺失时仍可计算 resetAt 供倒计时展示', () => {
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_7d_reset_after_seconds: 60,
|
||||
codex_usage_updated_at: '2026-02-20T08:00:00Z'
|
||||
},
|
||||
'7d',
|
||||
new Date('2026-02-20T08:00:01Z')
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBeNull()
|
||||
expect(result.resetAt).toBe('2026-02-20T08:01:00.000Z')
|
||||
})
|
||||
})
|
||||
@@ -145,6 +145,25 @@ describe('usageLoadQueue', () => {
|
||||
expect(Math.abs(timestamps[1] - timestamps[0])).toBeLessThan(50)
|
||||
})
|
||||
|
||||
it('Antigravity 平台直接执行,不排队', async () => {
|
||||
const timestamps: number[] = []
|
||||
const makeFn = () => async () => {
|
||||
timestamps.push(Date.now())
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
const acc1 = makeAccount('antigravity', 'oauth', { host: '1.2.3.4', port: 8080 })
|
||||
const acc2 = makeAccount('antigravity', 'oauth', { host: '1.2.3.4', port: 8080 })
|
||||
|
||||
const p1 = enqueueUsageRequest(acc1, makeFn())
|
||||
const p2 = enqueueUsageRequest(acc2, makeFn())
|
||||
|
||||
await Promise.all([p1, p2])
|
||||
|
||||
expect(timestamps).toHaveLength(2)
|
||||
expect(Math.abs(timestamps[1] - timestamps[0])).toBeLessThan(50)
|
||||
})
|
||||
|
||||
it('OpenAI 平台直接执行,不排队', async () => {
|
||||
const timestamps: number[] = []
|
||||
const makeFn = () => async () => {
|
||||
|
||||
140
frontend/src/utils/codexUsage.ts
Normal file
140
frontend/src/utils/codexUsage.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { CodexUsageSnapshot } from '@/types'
|
||||
|
||||
export interface ResolvedCodexUsageWindow {
|
||||
usedPercent: number | null
|
||||
resetAt: string | null
|
||||
}
|
||||
|
||||
type WindowKind = '5h' | '7d'
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const n = Number(value)
|
||||
if (Number.isFinite(n)) return n
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const trimmed = value.trim()
|
||||
return trimmed === '' ? null : trimmed
|
||||
}
|
||||
|
||||
function asISOTime(value: unknown): string | null {
|
||||
const raw = asString(value)
|
||||
if (!raw) return null
|
||||
const date = new Date(raw)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
function resolveLegacy5h(snapshot: Record<string, unknown>): {
|
||||
used: number | null
|
||||
resetAfterSeconds: number | null
|
||||
} {
|
||||
const primaryWindow = asNumber(snapshot.codex_primary_window_minutes)
|
||||
const secondaryWindow = asNumber(snapshot.codex_secondary_window_minutes)
|
||||
const primaryUsed = asNumber(snapshot.codex_primary_used_percent)
|
||||
const secondaryUsed = asNumber(snapshot.codex_secondary_used_percent)
|
||||
const primaryReset = asNumber(snapshot.codex_primary_reset_after_seconds)
|
||||
const secondaryReset = asNumber(snapshot.codex_secondary_reset_after_seconds)
|
||||
|
||||
if (primaryWindow != null && primaryWindow <= 360) {
|
||||
return { used: primaryUsed, resetAfterSeconds: primaryReset }
|
||||
}
|
||||
if (secondaryWindow != null && secondaryWindow <= 360) {
|
||||
return { used: secondaryUsed, resetAfterSeconds: secondaryReset }
|
||||
}
|
||||
return { used: secondaryUsed, resetAfterSeconds: secondaryReset }
|
||||
}
|
||||
|
||||
function resolveLegacy7d(snapshot: Record<string, unknown>): {
|
||||
used: number | null
|
||||
resetAfterSeconds: number | null
|
||||
} {
|
||||
const primaryWindow = asNumber(snapshot.codex_primary_window_minutes)
|
||||
const secondaryWindow = asNumber(snapshot.codex_secondary_window_minutes)
|
||||
const primaryUsed = asNumber(snapshot.codex_primary_used_percent)
|
||||
const secondaryUsed = asNumber(snapshot.codex_secondary_used_percent)
|
||||
const primaryReset = asNumber(snapshot.codex_primary_reset_after_seconds)
|
||||
const secondaryReset = asNumber(snapshot.codex_secondary_reset_after_seconds)
|
||||
|
||||
if (primaryWindow != null && primaryWindow >= 10000) {
|
||||
return { used: primaryUsed, resetAfterSeconds: primaryReset }
|
||||
}
|
||||
if (secondaryWindow != null && secondaryWindow >= 10000) {
|
||||
return { used: secondaryUsed, resetAfterSeconds: secondaryReset }
|
||||
}
|
||||
return { used: primaryUsed, resetAfterSeconds: primaryReset }
|
||||
}
|
||||
|
||||
function resolveFromSeconds(
|
||||
snapshot: Record<string, unknown>,
|
||||
resetAfterSeconds: number | null
|
||||
): string | null {
|
||||
if (resetAfterSeconds == null) return null
|
||||
|
||||
const baseRaw = asString(snapshot.codex_usage_updated_at)
|
||||
const base = baseRaw ? new Date(baseRaw) : new Date()
|
||||
if (Number.isNaN(base.getTime())) return null
|
||||
|
||||
const sec = Math.max(0, resetAfterSeconds)
|
||||
const resetAt = new Date(base.getTime() + sec * 1000)
|
||||
return resetAt.toISOString()
|
||||
}
|
||||
|
||||
function applyExpiredRule(
|
||||
window: ResolvedCodexUsageWindow,
|
||||
now: Date
|
||||
): ResolvedCodexUsageWindow {
|
||||
if (window.usedPercent == null || !window.resetAt) return window
|
||||
const resetDate = new Date(window.resetAt)
|
||||
if (Number.isNaN(resetDate.getTime())) return window
|
||||
if (resetDate.getTime() <= now.getTime()) {
|
||||
return { usedPercent: 0, resetAt: resetDate.toISOString() }
|
||||
}
|
||||
return window
|
||||
}
|
||||
|
||||
export function resolveCodexUsageWindow(
|
||||
snapshot: (CodexUsageSnapshot & Record<string, unknown>) | null | undefined,
|
||||
window: WindowKind,
|
||||
now: Date = new Date()
|
||||
): ResolvedCodexUsageWindow {
|
||||
if (!snapshot) {
|
||||
return { usedPercent: null, resetAt: null }
|
||||
}
|
||||
|
||||
const typedSnapshot = snapshot as Record<string, unknown>
|
||||
let usedPercent: number | null
|
||||
let resetAfterSeconds: number | null
|
||||
let resetAt: string | null
|
||||
|
||||
if (window === '5h') {
|
||||
usedPercent = asNumber(typedSnapshot.codex_5h_used_percent)
|
||||
resetAfterSeconds = asNumber(typedSnapshot.codex_5h_reset_after_seconds)
|
||||
resetAt = asISOTime(typedSnapshot.codex_5h_reset_at)
|
||||
if (usedPercent == null || (resetAfterSeconds == null && !resetAt)) {
|
||||
const legacy = resolveLegacy5h(typedSnapshot)
|
||||
if (usedPercent == null) usedPercent = legacy.used
|
||||
if (resetAfterSeconds == null) resetAfterSeconds = legacy.resetAfterSeconds
|
||||
}
|
||||
} else {
|
||||
usedPercent = asNumber(typedSnapshot.codex_7d_used_percent)
|
||||
resetAfterSeconds = asNumber(typedSnapshot.codex_7d_reset_after_seconds)
|
||||
resetAt = asISOTime(typedSnapshot.codex_7d_reset_at)
|
||||
if (usedPercent == null || (resetAfterSeconds == null && !resetAt)) {
|
||||
const legacy = resolveLegacy7d(typedSnapshot)
|
||||
if (usedPercent == null) usedPercent = legacy.used
|
||||
if (resetAfterSeconds == null) resetAfterSeconds = legacy.resetAfterSeconds
|
||||
}
|
||||
}
|
||||
|
||||
if (!resetAt) {
|
||||
resetAt = resolveFromSeconds(typedSnapshot, resetAfterSeconds)
|
||||
}
|
||||
|
||||
return applyExpiredRule({ usedPercent, resetAt }, now)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Shared URL builder for iframe-embedded pages.
|
||||
* Used by PurchaseSubscriptionView and CustomPageView to build consistent URLs
|
||||
* Used by CustomPageView to build consistent URLs
|
||||
* with user_id, token, theme, lang, ui_mode, src_host, and src parameters.
|
||||
*/
|
||||
|
||||
|
||||
Reference in New Issue
Block a user