mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-07 17:00:20 +08:00
fix(codex): 修复额度窗口过期展示并补齐高覆盖测试
- 后端新增绝对重置时间字段计算(codex_5h_reset_at/codex_7d_reset_at) - 前端统一窗口解析逻辑:绝对时间优先,updated_at+seconds 回退,过期自动归零 - 新增后端与前端单元测试,覆盖关键边界与异常场景
This commit is contained in:
130
frontend/src/utils/codexUsage.ts
Normal file
130
frontend/src/utils/codexUsage.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user