fix(openai): harden reset-credit refresh and account recovery

Review follow-ups on the reset-credit caching flow:

- Recover account state BEFORE (and independently of) the reset-credit
  display cache. A failed cache refresh could previously abort the run and
  leave the account rate-limited — the very reason the credit was spent
  (#3672 / #3740). The recovered account row is now returned even when the
  cache refresh fails.
- Run the post-reset bookkeeping on a detached, time-boxed context and give
  the panel reset call a larger timeout. A client abort no longer strands a
  consumed (non-refundable) credit with an unrecovered account, and the
  chained upstream calls can no longer exceed the client timeout and invite a
  retry that spends a second credit.
- Persist the reset-credit snapshot through POST /accounts/:id/quota/refresh
  instead of a side-effecting GET flag, so the write is covered by the audit
  middleware. A rejected snapshot write now degrades to cache_persisted=false
  instead of turning a successful upstream read into a 502 that left the card
  without a credit count and the reset button permanently disabled.
- Reject snapshots whose positive count carries no expiration entries, and
  drop expired credits (clamping the count) when rehydrating, so a stale
  cache can no longer light up the reset button.
- Keep nil quota / rate-limit services nil in the handler's interface fields;
  storing a nil *Service made the "not enabled" guards non-nil.
- Time-box the usage-refresh suppression and reuse handleAccountUpdated so the
  patched row also enters the auto-refresh silent window.
This commit is contained in:
shaw
2026-08-03 14:40:55 +08:00
parent f970bd48c9
commit 54a2bcfd15
14 changed files with 681 additions and 215 deletions
+27 -10
View File
@@ -844,24 +844,41 @@ export interface OpenAIQuotaResetResult {
| '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,
options?: { persistResetCredits?: boolean }
): Promise<OpenAIQuotaUsage> {
const { data } = await apiClient.get<OpenAIQuotaUsage>(`/admin/openai/accounts/${id}/quota`, {
params: options?.persistResetCredits === true ? { persist_reset_credits: true } : undefined
})
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
}
@@ -998,7 +1015,7 @@ export const accountsAPI = {
batchRefresh,
setPrivacy,
revertProxyFallback,
queryOpenAIQuota,
refreshOpenAIQuota,
resetOpenAIQuota,
createSparkShadow,
getUpstreamBillingProbeSettings,