Files
sub2api/frontend/src/utils/codexUsage.ts
yangjianbo 10636d8a1f fix(codex): 修复额度窗口过期展示并补齐高覆盖测试
- 后端新增绝对重置时间字段计算(codex_5h_reset_at/codex_7d_reset_at)

- 前端统一窗口解析逻辑:绝对时间优先,updated_at+seconds 回退,过期自动归零

- 新增后端与前端单元测试,覆盖关键边界与异常场景
2026-02-22 21:04:52 +08:00

131 lines
4.9 KiB
TypeScript

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)
}