Skip to content

feat(keys): add per-key soft quota reset (costResetAt)#929

Merged
ding113 merged 5 commits into
ding113:devfrom
sususu98:feat/key-cost-reset-v2
Mar 16, 2026
Merged

feat(keys): add per-key soft quota reset (costResetAt)#929
ding113 merged 5 commits into
ding113:devfrom
sususu98:feat/key-cost-reset-v2

Conversation

@sususu98

@sususu98 sususu98 commented Mar 16, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add per-key costResetAt column for soft quota reset without affecting other keys under the same user
  • Use MAX(key.costResetAt, user.costResetAt) logic so whichever reset is more recent takes effect
  • Expose POST /api/actions/keys/resetKeyLimitsOnly admin-only endpoint
  • Add reset button with confirmation dialog in key edit form (Dashboard UI)

Motivation

Previously, resetting quota limits was only possible at the user level (resetUserLimitsOnly), which reset ALL keys at once. Admins needed the ability to reset a single API key's accumulated usage independently.

Related

Design

Soft reset only - sets key.costResetAt = NOW(), never deletes logs or data.

MAX logic - resolveKeyCostResetAt(keyCRA, userCRA) returns the more recent of key-level and user-level reset timestamps. When key.costResetAt is null (never reset), behavior is identical to the old code path.

Changes

Database

  • Add nullable cost_reset_at column to keys table (migration 0083_aromatic_wolverine.sql)

Core Logic

  • New resolveKeyCostResetAt() utility in src/lib/rate-limit/cost-reset-utils.ts
  • rate-limit-guard.ts: all 5 key-level checks (total/5h/daily/weekly/monthly) use resolved resetAt
  • service.ts: fix 365 -> Infinity for total cost queries (6 places) - prevents 365-day expiry on total limits

Repository

  • All 7 SELECT queries in key.ts + createKey/updateKey include costResetAt
  • New resetKeyCostResetAt() function with auth cache invalidation
  • transformers.ts: toKey() hydrates costResetAt
  • api-key-auth-cache.ts: cache hydration includes costResetAt

Quota Display (3 paths)

  • key-quota.ts: uses resolved resetAt
  • keys.ts (getKeyLimitUsage): uses resolved resetAt
  • my-usage.ts: splits into separate keyClipStart/userClipStart with independent clipped ranges
  • quotas/users/page.tsx: per-key resolveKeyCostResetAt + Infinity for batch queries

Reset Action + API

  • resetKeyLimitsOnly(keyId) in keys.ts - admin-only, sets costResetAt, clears Redis cache
  • clearSingleKeyCostCache() in cost-cache-cleanup.ts (4 Redis patterns)
  • Registered POST /api/actions/keys/resetKeyLimitsOnly endpoint

Frontend

  • Reset button with AlertDialog in edit-key-form.tsx
  • Props plumbing through edit-key-dialog.tsx, user-key-table-row.tsx
  • i18n for 5 languages (zh-CN, zh-TW, en, ja, ru)

Known Limitations


Description enhanced by Claude AI

Greptile Summary

This PR adds per-key soft quota reset (costResetAt) so admins can reset a single API key's accumulated cost window without affecting other keys under the same user. The core resolveKeyCostResetAt(key.costResetAt, user.costResetAt) utility correctly returns the more-recent of the two timestamps, ensuring the user-level reset still acts as a floor.

Key changes:

  • DB: Nullable cost_reset_at column added to keys table (migration 0083).
  • Core logic: All 5 key-level rate-limit checks in rate-limit-guard.ts use the resolved costResetAt; user-level checks are unaffected, preserving isolation.
  • Bug fix – 365 → Infinity: Six sumKeyTotalCost/sumUserTotalCost calls in service.ts previously used a 365-day look-back for "total" limits. Changing to Infinity is correct but is a breaking behavior change for instances older than one year — existing users with limitTotalUsd set will see their measured usage increase on upgrade.
  • Reset action: resetKeyLimitsOnly is admin-only, commits costResetAt = NOW, invalidates the auth Redis cache, then clears key-scoped cost/lease Redis counters via clearSingleKeyCostCache. There is a brief eventual-consistency window between the DB commit and the Redis counter sweep.
  • Frontend: Reset button correctly gated behind isAdmin prop after addressing the previous review thread. Minor cosmetic issue: both <h3> and <h4> in the reset section use the same resetLimits.title translation key, rendering the title twice.
  • Quota display (my-usage.ts, key-quota.ts, quotas/users/page.tsx): All three display paths correctly apply the resolved costResetAt as the window clip start.

Confidence Score: 4/5

  • Safe to merge with minor fixes — the core reset logic is sound, admin-only gate is in place, and no data is deleted.
  • The feature is well-architected: the MAX-logic utility is correct, all rate-limit check sites are updated consistently, the Redis cleanup is comprehensive, and the UI is properly restricted to admins. The score is 4 rather than 5 due to: (1) the 365 → Infinity change being a silent breaking change for long-running deployments that should be communicated in release notes, (2) the duplicate title in the reset UI, and (3) the missing fail-open comment in the key auth cache hydration function.
  • src/lib/rate-limit/service.ts (365→Infinity behavior change), src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx (duplicate title)

Important Files Changed

Filename Overview
src/lib/rate-limit/service.ts All 6 total-cost DB queries changed from 365 days to Infinity. Correct fix for lifetime limits but is a breaking behavior change for deployments older than one year; needs release note.
src/app/v1/_lib/proxy/rate-limit-guard.ts All 5 key-level rate limit checks (total/5h/daily/weekly/monthly) now use resolveKeyCostResetAt for cost_reset_at parameter; user-level checks correctly continue to use only user.costResetAt.
src/actions/keys.ts New resetKeyLimitsOnly action is admin-gated and correctly chains DB update → auth-cache invalidation → Redis cost-cache cleanup, with a minor eventual-consistency window between steps 2 and 3.
src/lib/redis/cost-cache-cleanup.ts New clearSingleKeyCostCache function correctly scans and deletes 4 key-scoped Redis patterns (period counters, total-cost cache, and lease keys) without touching user-level caches.
src/lib/security/api-key-auth-cache.ts costResetAt correctly hydrated in hydrateKeyFromCache with fail-open behavior, but lacks the explanatory comment present in hydrateUserFromCache.
src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx Reset quota UI correctly gated behind {isAdmin && ...}. Minor issue: both <h3> and <h4> in the reset section render the same resetLimits.title translation key, duplicating the title text.
drizzle/0083_aromatic_wolverine.sql Simple nullable cost_reset_at timestamptz column addition to keys table — safe additive migration with no data loss risk.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Admin clicks Reset Quota] --> B[resetKeyLimitsOnly action]
    B --> C{Is admin?}
    C -- No --> D[Return PERMISSION_DENIED]
    C -- Yes --> E[UPDATE keys SET cost_reset_at = NOW in DB]
    E --> F[Invalidate Redis auth cache for key]
    F --> G[clearSingleKeyCostCache]
    G --> H[Scan and delete key period counters]
    G --> I[Scan and delete total cost cache]
    G --> J[Scan and delete lease cache]
    H & I & J --> K[Reset complete]

    L[Incoming API request] --> M[ProxyRateLimitGuard]
    M --> N[resolveKeyCostResetAt]
    N --> O{key.costResetAt vs user.costResetAt}
    O --> P[Return MAX of both timestamps]
    P --> Q[Use resolved timestamp to clip period window]
    Q --> R{Cost within limit?}
    R -- Yes --> S[Allow request]
    R -- No --> T[Reject with rate limit error]
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx
Line: 430-438

Comment:
**Duplicate section title rendered twice**

Both the outer `<h3>` at line 431 and the inner `<h4>` at line 435 render the same `t("resetLimits.title")` translation key, causing the section title to appear twice in the UI. The `<h4>` inside the amber card should either use a different, more descriptive translation key (e.g. `resetLimits.cardTitle`) or be removed entirely.

```suggestion
      <section className="rounded-lg border border-muted p-4 space-y-3 mt-6">
          <h3 className="text-sm font-medium">{t("resetLimits.title")}</h3>
          <div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-3">
            <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
              <div className="space-y-1">
                <p className="text-xs text-muted-foreground">{t("resetLimits.description")}</p>
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/lib/security/api-key-auth-cache.ts
Line: 144-146

Comment:
**Missing fail-open comment for `costResetAt` in key cache hydration**

`hydrateUserFromCache` (line 177) has an explicit comment explaining that a failed `costResetAt` parse is intentionally fail-open because it only affects quota counting, not access control. The same logic applies to `hydrateKeyFromCache`, but the comment is absent here. Without the comment, a future reviewer may mistake the missing guard as an oversight and add `if (key.costResetAt != null && !costResetAt) return null;`, which would break key lookups for any cached key with a reset timestamp.

```suggestion
  const costResetAt = parseOptionalDate(key.costResetAt);
  if (key.expiresAt != null && !expiresAt) return null;
  if (key.deletedAt != null && !deletedAt) return null;
  // costResetAt: intentional fail-open on invalid date -- affects quota counting window, not access control
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/lib/rate-limit/service.ts
Line: 379-381

Comment:
**Behavior change: `365``Infinity` for total cost queries**

All six `sumKeyTotalCost` / `sumUserTotalCost` calls previously passed `365` (days) as the look-back window, effectively capping total-cost accumulation at one year. Switching to `Infinity` is the correct fix for a "lifetime total" limit, but it is a **breaking behavior change for existing deployments**: any user or key with a `limitTotalUsd` who has more than 365 days of history will now see their total shoot up and may unexpectedly hit—or appear to already be over—their limit on the first request after upgrading.

Consider communicating this change in release notes or adding a migration note so operators can adjust their limits proactively.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/actions/keys.ts
Line: 845-873

Comment:
**Redis cost-cache cleared after DB commit, not atomically**

The reset flow is:
1. `resetKeyCostResetAt(keyId, new Date())` — commits `costResetAt = NOW` to Postgres and invalidates the auth Redis cache.
2. (Separately) `clearSingleKeyCostCache(...)` — scans and deletes rate-limit Redis keys.

Between steps 1 and 2 there is a window (typically a few milliseconds, but visible under load) during which a fresh request can read the updated `costResetAt` from the just-invalidated auth cache miss (DB read) and attempt rate-limit checks against the **un-cleared** Redis period counters. This means the rate-limiter may briefly see the new `costResetAt` clipping the time window while the underlying Redis counter still reflects the pre-reset accumulated value.

The PR description acknowledges "In-flight requests started before reset may still write costs after reset", but this is a slightly different scenario: it can also allow a request arriving *after* the reset to be incorrectly rate-limited by the old counter for a short window.

This is a minor eventual-consistency gap that is probably acceptable for a soft quota reset, but it should be documented with a comment near the `clearSingleKeyCostCache` call so future maintainers understand the design choice.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: b688b9e

Greptile also left 2 inline comments on this PR.

- Add costResetAt column to keys table with migration
- Implement resolveKeyCostResetAt() using MAX(key, user) logic
- Update all 7 repository SELECT queries + create/update/auth cache
- Update rate-limit-guard: all 5 key-level checks use resolved resetAt
- Fix service.ts: 365 -> Infinity for total cost queries (6 places)
- Update 3 quota display paths (key-quota, keys, my-usage) with resolved resetAt
- Split my-usage clipStart into separate key/user ranges
- Fix quotas/users/page.tsx batch queries: per-key resolved + Infinity
- Add resetKeyLimitsOnly action with Redis cache cleanup
- Register POST /api/actions/keys/resetKeyLimitsOnly endpoint
- Add reset button with confirmation dialog in edit-key-form
- Add i18n translations for 5 languages (zh-CN, zh-TW, en, ja, ru)
@coderabbitai

coderabbitai Bot commented Mar 16, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

本PR为API密钥添加了成本重置功能:数据库 schema 扩展 cost_reset_at、在仓库与类型中暴露 costResetAt、新增 resolveKeyCostResetAt 合并规则、清理单密钥 Redis 缓存、重置密钥限额的后端路由与前端“重置配额”对话框,并在配额/速率限制路径中使用合并后的重置时间。

Changes

Cohort / File(s) Summary
数据库 & 迁移
drizzle/0083_aromatic_wolverine.sql, src/drizzle/schema.ts, drizzle/meta/_journal.json
keys 表添加 cost_reset_at 列并追加迁移日志条目。
类型定义
src/types/key.ts, src/types/user.ts, src/repository/_shared/transformers.ts
在 Key/创建/更新数据及用户展示类型中增加 costResetAt 映射与输出。
仓库变更
src/repository/key.ts
在所有 Key 读写中携带 costResetAt,并新增 resetKeyCostResetAt(keyId, resetAt) 更新接口与缓存失效逻辑。
成本重置工具
src/lib/rate-limit/cost-reset-utils.ts
新增 resolveKeyCostResetAt,按规则合并 key 与 user 层的 costResetAt(返回 later/null)。
Redis 缓存清理
src/lib/redis/cost-cache-cleanup.ts
新增 clearSingleKeyCostCache 与对应选项类型,用于删除单个密钥的成本缓存键。
安全/认证缓存
src/lib/security/api-key-auth-cache.ts
密钥缓存 hydration 支持并携带 costResetAt 字段。
配额/速率限制逻辑
src/actions/key-quota.ts, src/actions/my-usage.ts, src/app/v1/_lib/proxy/rate-limit-guard.ts, src/lib/rate-limit/service.ts
在 key/user 配额与速率限制计算中集成 resolveKeyCostResetAt;引入 key/user 各自的 clipStart;将部分 total-cost 查询从 365 天改为 Infinity。
动作与 API 路由
src/actions/keys.ts, src/app/api/actions/[...route]/route.ts
新增 admin 操作 resetKeyLimitsOnly 及对应 OpenAPI 路由;新增 resetKeyCostResetAt 仓库调用;多处错误响应增加 errorCode 返回字段。
用户/前端 UI
src/actions/users.ts, src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx, src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx, src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx, src/app/[locale]/dashboard/quotas/users/page.tsx
在用户接口与表格/对话框中暴露并规范 costResetAt;在编辑表单添加“重置配额”UI(确认对话、加载态、提示),并调用后端重置操作与缓存清理。
本地化
messages/en/quota.json, messages/ja/quota.json, messages/ru/quota.json, messages/zh-CN/quota.json, messages/zh-TW/quota.json
为 quota 编辑对话添加 editDialog.resetLimits 文案块,支持重置配额流程的标题/说明/确认/错误/成功文本。
测试
tests/integration/usage-ledger.test.ts
仅格式化调整,无行为变更。

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed 标题准确总结了主要变更:添加每个密钥级别的软配额重置功能(costResetAt)。
Description check ✅ Passed 拉取请求描述与变更集高度相关,清晰阐述了功能意图、设计决策和实现范围。

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the flexibility of quota management by introducing the ability to perform soft resets on a per-API key basis. Previously, quota resets were only possible at the user level, impacting all associated keys. This change provides more granular control for administrators, allowing them to manage individual key usage more effectively without disrupting other services or users. The implementation includes database schema updates, core logic adjustments, API endpoints, and a user interface for seamless integration.

Highlights

  • Per-Key Soft Quota Reset: Introduced a new costResetAt column in the keys table, allowing administrators to reset usage limits for individual API keys without affecting other keys under the same user.
  • Quota Reset Logic: Implemented a MAX(key.costResetAt, user.costResetAt) logic to determine the effective reset timestamp, ensuring that the most recent reset (either key-level or user-level) takes precedence.
  • Admin API Endpoint: Exposed a new admin-only POST /api/actions/keys/resetKeyLimitsOnly endpoint to programmatically trigger a soft quota reset for a specific key.
  • Dashboard UI Integration: Added a 'Reset Quota' button with a confirmation dialog to the key edit form in the Dashboard UI, providing a user-friendly way for admins to manage key-level quotas.
  • Total Cost Query Fix: Corrected total cost queries from using a 365-day expiry to Infinity, ensuring that total limits are truly cumulative unless explicitly reset.
Changelog
  • drizzle/0083_aromatic_wolverine.sql
    • Added a new cost_reset_at timestamp column to the keys table.
  • messages/en/quota.json
    • Added new internationalization strings for the 'Reset Quota' feature in English.
  • messages/ja/quota.json
    • Added new internationalization strings for the 'Reset Quota' feature in Japanese.
  • messages/ru/quota.json
    • Added new internationalization strings for the 'Reset Quota' feature in Russian.
  • messages/zh-CN/quota.json
    • Added new internationalization strings for the 'Reset Quota' feature in Simplified Chinese.
  • messages/zh-TW/quota.json
    • Added new internationalization strings for the 'Reset Quota' feature in Traditional Chinese.
  • src/actions/key-quota.ts
    • Imported resolveKeyCostResetAt utility.
    • Updated quota usage calculation to use the resolved costResetAt for clipping time ranges.
  • src/actions/keys.ts
    • Imported resolveKeyCostResetAt and resetKeyCostResetAt.
    • Added a new resetKeyLimitsOnly action for admin-only key quota resets, including Redis cache invalidation.
    • Adjusted editKey function to handle provider group updates more cleanly.
    • Updated getKeyLimitUsage to use the resolved costResetAt for time range clipping.
  • src/actions/my-usage.ts
    • Imported resolveKeyCostResetAt.
    • Modified getMyQuota to differentiate and apply keyClipStart and userClipStart based on resolved key and user costResetAt values.
  • src/actions/users.ts
    • Included costResetAt in the UserKeyDisplay object returned by getUsers, getUsersBatch, and getUsersBatchCore.
  • src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx
    • Updated the EditKeyDialogProps interface to include costResetAt.
  • src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx
    • Added state for isResettingLimits and resetLimitsDialogOpen.
    • Implemented handleResetLimitsOnly function to call the new reset key limits action.
    • Integrated a 'Reset Quota' section with an AlertDialog for confirmation and UI feedback.
  • src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx
    • Passed the costResetAt property to the EditKeyForm component.
  • src/app/[locale]/dashboard/quotas/users/page.tsx
    • Imported resolveKeyCostResetAt.
    • Updated the logic for keyResetAtMap to use resolveKeyCostResetAt.
    • Changed sumUserTotalCostBatch and sumKeyTotalCostBatchByIds calls to use Infinity instead of undefined for max age.
  • src/app/v1/_lib/proxy/rate-limit-guard.ts
    • Imported resolveKeyCostResetAt.
    • Updated all key-level rate limit checks (total, 5h, daily, weekly, monthly) to use the keyCostResetAt resolved from both key and user costResetAt values.
  • src/drizzle/schema.ts
    • Added costResetAt field with timestamp with time zone type to the keys table schema.
  • src/lib/rate-limit/cost-reset-utils.ts
    • Added a new utility function resolveKeyCostResetAt to determine the effective reset timestamp based on key and user values.
  • src/lib/rate-limit/service.ts
    • Changed the maxAgeDays parameter from 365 to Infinity in sumKeyTotalCost and sumUserTotalCost calls within checkTotalCostLimit.
  • src/lib/redis/cost-cache-cleanup.ts
    • Added ClearSingleKeyCostCacheOptions interface.
    • Implemented clearSingleKeyCostCache function to scan and delete Redis cost-cache keys for a specific key.
  • src/lib/security/api-key-auth-cache.ts
    • Hydrated the costResetAt property when creating a Key object from cached payload.
  • src/repository/_shared/transformers.ts
    • Transformed dbKey.costResetAt to a Date object when converting database results to Key objects.
  • src/repository/key.ts
    • Updated findKeyById, findKeyList, findKeyListBatch, createKey, updateKey, findActiveKeyByUserIdAndName, findActiveKeyByKeyString, and validateApiKeyAndGetUser to include the costResetAt field.
    • Added a new resetKeyCostResetAt function to update a key's costResetAt and invalidate its cache.
  • src/types/key.ts
    • Added costResetAt property to the Key, CreateKeyData, and UpdateKeyData interfaces.
  • src/types/user.ts
    • Added costResetAt property to the UserKeyDisplay interface.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@coderabbitai
coderabbitai Bot requested a review from ding113 March 16, 2026 04:48
@github-actions github-actions Bot added enhancement New feature or request area:UI area:core area:i18n size/L Large PR (< 1000 lines) size/XL Extra Large PR (> 1000 lines) and removed size/L Large PR (< 1000 lines) labels Mar 16, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a valuable feature for resetting soft quotas on a per-key basis, which is a significant improvement over the previous user-level only reset. The implementation is comprehensive, touching the database schema, core rate-limiting logic, repository layer, API, and frontend UI. The addition of the cost_reset_at field on keys and the MAX(key.costResetAt, user.costResetAt) resolution logic are well-implemented. The new admin endpoint and the corresponding UI in the key edit form are also solid additions. I've included one suggestion to improve the user experience on the frontend by avoiding a full page reload after a quota reset.

}
toast.success(t("resetLimits.success"));
setResetLimitsDialogOpen(false);
window.location.reload();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using window.location.reload() forces a full page reload, which can be a jarring user experience. It's better to use Next.js's router.refresh() to update the data from the server without losing client-side state. This will update the 'Last reset at' time in the dialog while keeping it open, which is a better user experience.

Suggested change
window.location.reload();
router.refresh();

Comment on lines +427 to +493
{/* Reset Quota Section - Less destructive (amber) */}
<section className="rounded-lg border border-muted p-4 space-y-3 mt-6">
<h3 className="text-sm font-medium">{t("resetLimits.title")}</h3>
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<h4 className="text-sm font-medium text-amber-700 dark:text-amber-400">
{t("resetLimits.title")}
</h4>
<p className="text-xs text-muted-foreground">{t("resetLimits.description")}</p>
{keyData?.costResetAt && (
<p className="text-xs text-amber-600/80 dark:text-amber-400/80">
{t("resetLimits.lastResetAt", {
date: new Intl.DateTimeFormat(locale as string, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(keyData.costResetAt)),
})}
</p>
)}
</div>

<AlertDialog open={resetLimitsDialogOpen} onOpenChange={setResetLimitsDialogOpen}>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
className="border-amber-500/50 text-amber-700 hover:bg-amber-500/10 dark:text-amber-400 dark:hover:bg-amber-500/10"
>
<RotateCcw className="h-4 w-4 mr-2" />
{t("resetLimits.button")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("resetLimits.confirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("resetLimits.confirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isResettingLimits}>
{tCommon("cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleResetLimitsOnly();
}}
disabled={isResettingLimits}
className="bg-amber-600 text-white hover:bg-amber-700"
>
{isResettingLimits ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t("resetLimits.loading")}
</>
) : (
t("resetLimits.confirm")
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</section>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reset button visible to non-admin users

The entire "Reset Quota" section (lines 427-493) is rendered unconditionally for all users, but the server action resetKeyLimitsOnly enforces session.user.role !== "admin". Non-admin users will see the reset button, click it, and receive a confusing "Permission Denied" error. This section should be guarded by the isAdmin prop that is already available in this component.

Suggested change
{/* Reset Quota Section - Less destructive (amber) */}
<section className="rounded-lg border border-muted p-4 space-y-3 mt-6">
<h3 className="text-sm font-medium">{t("resetLimits.title")}</h3>
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<h4 className="text-sm font-medium text-amber-700 dark:text-amber-400">
{t("resetLimits.title")}
</h4>
<p className="text-xs text-muted-foreground">{t("resetLimits.description")}</p>
{keyData?.costResetAt && (
<p className="text-xs text-amber-600/80 dark:text-amber-400/80">
{t("resetLimits.lastResetAt", {
date: new Intl.DateTimeFormat(locale as string, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(keyData.costResetAt)),
})}
</p>
)}
</div>
<AlertDialog open={resetLimitsDialogOpen} onOpenChange={setResetLimitsDialogOpen}>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
className="border-amber-500/50 text-amber-700 hover:bg-amber-500/10 dark:text-amber-400 dark:hover:bg-amber-500/10"
>
<RotateCcw className="h-4 w-4 mr-2" />
{t("resetLimits.button")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("resetLimits.confirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("resetLimits.confirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isResettingLimits}>
{tCommon("cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleResetLimitsOnly();
}}
disabled={isResettingLimits}
className="bg-amber-600 text-white hover:bg-amber-700"
>
{isResettingLimits ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t("resetLimits.loading")}
</>
) : (
t("resetLimits.confirm")
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</section>
{/* Reset Quota Section - Admin only, less destructive (amber) */}
{isAdmin && (
<section className="rounded-lg border border-muted p-4 space-y-3 mt-6">
<h3 className="text-sm font-medium">{t("resetLimits.title")}</h3>
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<h4 className="text-sm font-medium text-amber-700 dark:text-amber-400">
{t("resetLimits.title")}
</h4>
<p className="text-xs text-muted-foreground">{t("resetLimits.description")}</p>
{keyData?.costResetAt && (
<p className="text-xs text-amber-600/80 dark:text-amber-400/80">
{t("resetLimits.lastResetAt", {
date: new Intl.DateTimeFormat(locale as string, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(keyData.costResetAt)),
})}
</p>
)}
</div>
<AlertDialog open={resetLimitsDialogOpen} onOpenChange={setResetLimitsDialogOpen}>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
className="border-amber-500/50 text-amber-700 hover:bg-amber-500/10 dark:text-amber-400 dark:hover:bg-amber-500/10"
>
<RotateCcw className="h-4 w-4 mr-2" />
{t("resetLimits.button")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("resetLimits.confirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("resetLimits.confirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isResettingLimits}>
{tCommon("cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleResetLimitsOnly();
}}
disabled={isResettingLimits}
className="bg-amber-600 text-white hover:bg-amber-700"
>
{isResettingLimits ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t("resetLimits.loading")}
</>
) : (
t("resetLimits.confirm")
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</section>
)}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx
Line: 427-493

Comment:
**Reset button visible to non-admin users**

The entire "Reset Quota" section (lines 427-493) is rendered unconditionally for all users, but the server action `resetKeyLimitsOnly` enforces `session.user.role !== "admin"`. Non-admin users will see the reset button, click it, and receive a confusing "Permission Denied" error. This section should be guarded by the `isAdmin` prop that is already available in this component.

```suggestion
      {/* Reset Quota Section - Admin only, less destructive (amber) */}
      {isAdmin && (
      <section className="rounded-lg border border-muted p-4 space-y-3 mt-6">
        <h3 className="text-sm font-medium">{t("resetLimits.title")}</h3>
        <div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-3">
          <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
            <div className="space-y-1">
              <h4 className="text-sm font-medium text-amber-700 dark:text-amber-400">
                {t("resetLimits.title")}
              </h4>
              <p className="text-xs text-muted-foreground">{t("resetLimits.description")}</p>
              {keyData?.costResetAt && (
                <p className="text-xs text-amber-600/80 dark:text-amber-400/80">
                  {t("resetLimits.lastResetAt", {
                    date: new Intl.DateTimeFormat(locale as string, {
                      dateStyle: "medium",
                      timeStyle: "short",
                    }).format(new Date(keyData.costResetAt)),
                  })}
                </p>
              )}
            </div>

            <AlertDialog open={resetLimitsDialogOpen} onOpenChange={setResetLimitsDialogOpen}>
              <AlertDialogTrigger asChild>
                <Button
                  type="button"
                  variant="outline"
                  className="border-amber-500/50 text-amber-700 hover:bg-amber-500/10 dark:text-amber-400 dark:hover:bg-amber-500/10"
                >
                  <RotateCcw className="h-4 w-4 mr-2" />
                  {t("resetLimits.button")}
                </Button>
              </AlertDialogTrigger>
              <AlertDialogContent>
                <AlertDialogHeader>
                  <AlertDialogTitle>{t("resetLimits.confirmTitle")}</AlertDialogTitle>
                  <AlertDialogDescription>
                    {t("resetLimits.confirmDescription")}
                  </AlertDialogDescription>
                </AlertDialogHeader>
                <AlertDialogFooter>
                  <AlertDialogCancel disabled={isResettingLimits}>
                    {tCommon("cancel")}
                  </AlertDialogCancel>
                  <AlertDialogAction
                    onClick={(e) => {
                      e.preventDefault();
                      handleResetLimitsOnly();
                    }}
                    disabled={isResettingLimits}
                    className="bg-amber-600 text-white hover:bg-amber-700"
                  >
                    {isResettingLimits ? (
                      <>
                        <Loader2 className="h-4 w-4 mr-2 animate-spin" />
                        {t("resetLimits.loading")}
                      </>
                    ) : (
                      t("resetLimits.confirm")
                    )}
                  </AlertDialogAction>
                </AlertDialogFooter>
              </AlertDialogContent>
            </AlertDialog>
          </div>
        </div>
      </section>
      )}
```

How can I resolve this? If you propose a fix, please make it concise.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

This PR adds per-key soft quota reset functionality (costResetAt) with a well-structured implementation across database, repository, service layer, and UI.

PR Size: XL

  • Lines changed: 4,567 (4,514 additions + 53 deletions)
  • Files changed: 27

Suggestion: Given the scope of changes, consider splitting into smaller PRs for future features of this size:

  1. Core logic (schema, repository, utility functions)
  2. Rate limit integration and bug fix
  3. Frontend UI changes

Issues Found

Category Critical High Medium Low
Logic/Bugs 0 0 0 0
Security 0 0 0 0
Error Handling 0 0 0 0
Types 0 0 0 0
Comments/Docs 0 0 0 0
Tests 0 0 1 0
Simplification 0 0 0 0

Medium Priority Issues (Consider Fixing)

[TEST-MISSING-COVERAGE] No tests for new utility and action functions

The following new functions lack test coverage:

  • `resolveKeyCostResetAt()` in `src/lib/rate-limit/cost-reset-utils.ts`
  • `resetKeyLimitsOnly()` action in `src/actions/keys.ts`
  • `clearSingleKeyCostCache()` in `src/lib/redis/cost-cache-cleanup.ts`

While these functions are relatively simple and follow established patterns, having test coverage would improve maintainability and catch regressions early.

Suggested test cases:
```typescript
// Test resolveKeyCostResetAt
describe("resolveKeyCostResetAt", () => {
it("returns null when both are null", () => {...});
it("returns key reset when user reset is null", () => {...});
it("returns user reset when key reset is null", () => {...});
it("returns max of key and user reset", () => {...});
});
```

Review Coverage

  • Logic and correctness - Clean implementation of MAX(key.costResetAt, user.costResetAt) logic
  • Security (OWASP Top 10) - Admin-only endpoint with proper session validation
  • Error handling - Proper try/catch with logging, graceful degradation on Redis failures
  • Type safety - Proper type definitions and transformers
  • Documentation accuracy - PR description matches implementation
  • Test coverage - Minor gaps noted above
  • Code clarity - Clean separation of concerns with new utility file

Notable Improvements

  • Bug fix: Changed `365` to `Infinity` for total cost queries to prevent unintended 365-day expiry on total limits
  • Clean architecture with new `cost-reset-utils.ts` utility file
  • Proper i18n coverage for all 5 languages

Automated review by Claude AI

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/repository/key.ts (1)

221-250: ⚠️ Potential issue | 🟠 Major

cost_reset_at 从通用 UpdateKeyData 类型中移除,防止非 admin 路径可能的绕过。

虽然当前调用方都没有通过 updateKey 传递 cost_reset_at,但该字段已被加入通用 UpdateKeyData 类型(src/types/key.ts:84)并在 updateKey 中被无条件处理(src/repository/key.ts:245)。这种结构上的开放性会增加未来被误用的风险——如果有新的非管理员路径使用 updateKey 并传入该字段,就会绕过专用的 resetKeyLimitsOnly 流程。

建议将 cost_reset_atUpdateKeyData 中删除,保留在 CreateKeyData 中;所有 cost 重置操作专门通过 resetKeyCostResetAt 进行,该函数已正确实现了缓存失效(src/repository/key.ts:468invalidateCachedKey 调用)。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/repository/key.ts` around lines 221 - 250, Remove cost_reset_at from the
generic UpdateKeyData type and stop handling it in the updateKey code path:
update the types so CreateKeyData keeps cost_reset_at but UpdateKeyData does
not, and remove the db mapping for costResetAt in the updateKey logic (the block
that sets dbData.costResetAt). Ensure all cost-reset changes go through
resetKeyCostResetAt (which calls invalidateCachedKey) and that
resetKeyLimitsOnly remains the admin-only path for limit resets.
🧹 Nitpick comments (2)
src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx (2)

100-102: 可选优化:考虑使用 router.refresh() 替代 window.location.reload()

当前使用完整页面重载来刷新数据。可以考虑使用更轻量的方式:

router.refresh();
queryClient.invalidateQueries();

这与表单提交成功后的刷新方式(第173行)保持一致。不过,由于 costResetAt 可能影响多个组件,完整重载也是可接受的保守选择。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`[locale]/dashboard/_components/user/forms/edit-key-form.tsx around
lines 100 - 102, Replace the full-page reload after successful reset (where
toast.success and setResetLimitsDialogOpen are called) with a lighter
client-side refresh: call router.refresh() and optionally
queryClient.invalidateQueries() to refresh affected data instead of
window.location.reload(); update the code path that currently calls
window.location.reload() to invoke router.refresh() (and invalidate queries for
keys related to costResetAt or user limits) so the behavior matches the other
successful-submit path.

427-493: 建议:考虑为重置限额按钮添加管理员权限检查。

重置限额区块目前对所有用户可见,但 resetKeyLimitsOnly 服务端操作仅限管理员使用。虽然服务端会正确拒绝非管理员请求,但在 UI 层隐藏该区块可以提供更好的用户体验。

♻️ 建议的修改
-      {/* Reset Quota Section - Less destructive (amber) */}
-      <section className="rounded-lg border border-muted p-4 space-y-3 mt-6">
+      {/* Reset Quota Section - Less destructive (amber) - Admin only */}
+      {isAdmin && (
+        <section className="rounded-lg border border-muted p-4 space-y-3 mt-6">
         ...
-      </section>
+        </section>
+      )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`[locale]/dashboard/_components/user/forms/edit-key-form.tsx around
lines 427 - 493, The Reset Quota section is shown to all users but the server
action (resetKeyLimitsOnly) is admin-only; update the UI to hide/disable this
section for non-admins by checking the admin flag (e.g., keyData.isAdmin,
currentUser.isAdmin, or the relevant auth/context value) before rendering the
section (or the AlertDialogTrigger/Button). Specifically, wrap or gate the
entire section that uses resetLimitsDialogOpen, AlertDialog, Button and the
handler handleResetLimitsOnly so it only renders for admins (or render a
disabled state with a tooltip) to prevent exposing the action to non-admin
users.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@drizzle/0083_aromatic_wolverine.sql`:
- Line 1: 删除手写迁移文件 0083_aromatic_wolverine.sql(包含 ALTER TABLE "keys" ADD COLUMN
"cost_reset_at" ...)并在已更新 src/drizzle/schema.ts(在 keys 表上添加 cost_reset_at
字段)后运行生成命令:bun run db:generate,以由工具生成规范的迁移文件;确保不要将手写 SQL 提交,验证新生成的迁移包含对
"cost_reset_at" 的变更并通过现有迁移测试/校验流程。

In `@messages/zh-TW/quota.json`:
- Line 332: Update the Traditional Chinese copy for the "confirmDescription"
entry in messages/zh-TW/quota.json by replacing the Simplified character "每周"
with the Traditional variant "每週" so the string reads with consistent zh-TW
wording; locate the "confirmDescription" key and change that substring only.

In `@src/app/api/actions/`[...route]/route.ts:
- Around line 353-354: The route metadata fields description and summary in
route.ts are hardcoded; replace them with i18n lookups: create corresponding
i18n keys (e.g. "route.resetKeyQuota.summary" and
"route.resetKeyQuota.description") in the locale resource files for zh-CN,
zh-TW, en, ja, ru, then use the project's i18n accessor (e.g. i18n.t or similar
used elsewhere) to populate summary and description instead of literal strings
so the route uses localized text at runtime.

In `@src/repository/key.ts`:
- Around line 460-472: The function resetKeyCostResetAt currently treats DB
update success as overall success while silently swallowing invalidateCachedKey
failures; change it so cache invalidation errors are observable: after the DB
update in resetKeyCostResetAt, await invalidateCachedKey(result[0].key) and if
it throws, log the error with context (include keyId and key) and rethrow or
return a distinct failure (do not .catch(() => {})). This makes callers see a
partial-failure (DB updated but cache stale) so they can handle or retry;
reference the functions resetKeyCostResetAt and invalidateCachedKey when making
the change.

---

Outside diff comments:
In `@src/repository/key.ts`:
- Around line 221-250: Remove cost_reset_at from the generic UpdateKeyData type
and stop handling it in the updateKey code path: update the types so
CreateKeyData keeps cost_reset_at but UpdateKeyData does not, and remove the db
mapping for costResetAt in the updateKey logic (the block that sets
dbData.costResetAt). Ensure all cost-reset changes go through
resetKeyCostResetAt (which calls invalidateCachedKey) and that
resetKeyLimitsOnly remains the admin-only path for limit resets.

---

Nitpick comments:
In `@src/app/`[locale]/dashboard/_components/user/forms/edit-key-form.tsx:
- Around line 100-102: Replace the full-page reload after successful reset
(where toast.success and setResetLimitsDialogOpen are called) with a lighter
client-side refresh: call router.refresh() and optionally
queryClient.invalidateQueries() to refresh affected data instead of
window.location.reload(); update the code path that currently calls
window.location.reload() to invoke router.refresh() (and invalidate queries for
keys related to costResetAt or user limits) so the behavior matches the other
successful-submit path.
- Around line 427-493: The Reset Quota section is shown to all users but the
server action (resetKeyLimitsOnly) is admin-only; update the UI to hide/disable
this section for non-admins by checking the admin flag (e.g., keyData.isAdmin,
currentUser.isAdmin, or the relevant auth/context value) before rendering the
section (or the AlertDialogTrigger/Button). Specifically, wrap or gate the
entire section that uses resetLimitsDialogOpen, AlertDialog, Button and the
handler handleResetLimitsOnly so it only renders for admins (or render a
disabled state with a tooltip) to prevent exposing the action to non-admin
users.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: dabe3f34-2bf8-45f4-8255-46077478e00e

📥 Commits

Reviewing files that changed from the base of the PR and between b7bad4e and 5ce72eb.

📒 Files selected for processing (27)
  • drizzle/0083_aromatic_wolverine.sql
  • drizzle/meta/0083_snapshot.json
  • messages/en/quota.json
  • messages/ja/quota.json
  • messages/ru/quota.json
  • messages/zh-CN/quota.json
  • messages/zh-TW/quota.json
  • src/actions/key-quota.ts
  • src/actions/keys.ts
  • src/actions/my-usage.ts
  • src/actions/users.ts
  • src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx
  • src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx
  • src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx
  • src/app/[locale]/dashboard/quotas/users/page.tsx
  • src/app/api/actions/[...route]/route.ts
  • src/app/v1/_lib/proxy/rate-limit-guard.ts
  • src/drizzle/schema.ts
  • src/lib/rate-limit/cost-reset-utils.ts
  • src/lib/rate-limit/service.ts
  • src/lib/redis/cost-cache-cleanup.ts
  • src/lib/security/api-key-auth-cache.ts
  • src/repository/_shared/transformers.ts
  • src/repository/key.ts
  • src/types/key.ts
  • src/types/user.ts
  • tests/integration/usage-ledger.test.ts

Comment thread drizzle/0083_aromatic_wolverine.sql Outdated
@@ -0,0 +1 @@
ALTER TABLE "keys" ADD COLUMN "cost_reset_at" timestamp with time zone; No newline at end of file

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

请移除手写迁移并改为自动生成。

当前是手写 SQL 迁移,违反仓库迁移流程约束。建议删除该文件后,在已更新 src/drizzle/schema.ts 的前提下执行 bun run db:generate 重新产出迁移。

As per coding guidelines: "Modify database schema in src/drizzle/schema.ts, then run bun run db:generate to generate migrations. Never create SQL migration files manually".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@drizzle/0083_aromatic_wolverine.sql` at line 1, 删除手写迁移文件
0083_aromatic_wolverine.sql(包含 ALTER TABLE "keys" ADD COLUMN "cost_reset_at"
...)并在已更新 src/drizzle/schema.ts(在 keys 表上添加 cost_reset_at 字段)后运行生成命令:bun run
db:generate,以由工具生成规范的迁移文件;确保不要将手写 SQL 提交,验证新生成的迁移包含对 "cost_reset_at"
的变更并通过现有迁移测试/校验流程。

Comment thread messages/zh-TW/quota.json Outdated
Comment on lines +353 to +354
description: "仅重置单个密钥的消费限额累计(不删除日志)",
summary: "重置密钥限额累计",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

新增路由文案仍为硬编码,未接入 i18n。

Line 353-354 的 description/summary 是直接字符串;按仓库规则,这类用户可见文案应走多语言资源。建议改为统一的 i18n key 映射(至少覆盖 zh-CN、zh-TW、en、ja、ru)。

As per coding guidelines "All user-facing strings must use i18n (5 languages supported: zh-CN, zh-TW, en, ja, ru). Never hardcode display text".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/actions/`[...route]/route.ts around lines 353 - 354, The route
metadata fields description and summary in route.ts are hardcoded; replace them
with i18n lookups: create corresponding i18n keys (e.g.
"route.resetKeyQuota.summary" and "route.resetKeyQuota.description") in the
locale resource files for zh-CN, zh-TW, en, ja, ru, then use the project's i18n
accessor (e.g. i18n.t or similar used elsewhere) to populate summary and
description instead of literal strings so the route uses localized text at
runtime.

Comment thread src/repository/key.ts
Comment on lines +460 to +472
export async function resetKeyCostResetAt(keyId: number, resetAt: Date | null): Promise<boolean> {
const result = await db
.update(keys)
.set({ costResetAt: resetAt, updatedAt: new Date() })
.where(and(eq(keys.id, keyId), isNull(keys.deletedAt)))
.returning({ id: keys.id, key: keys.key });

if (result.length > 0) {
await invalidateCachedKey(result[0].key).catch(() => {});
}

return result.length > 0;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

把 reset 做成完整且可观测的操作。

这里返回成功的前提只是 DB 更新成功,但 reset 语义还依赖缓存侧同步。现在鉴权缓存失效被静默吞掉;一旦 Redis 删除失败,后续请求仍可能命中旧 key 缓存并继续使用旧 costResetAt,最终表现成“重置成功但限额还没重置”。至少要把这类失败显式记录/上抛为 partial failure,而不是无声忽略。

Based on learnings: In src/lib/redis/cost-cache-cleanup.ts, best-effort cleanup is acceptable only because the DB reset already preserves correctness; that rationale does not hold for stale auth-cache entries carrying an old costResetAt.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/repository/key.ts` around lines 460 - 472, The function
resetKeyCostResetAt currently treats DB update success as overall success while
silently swallowing invalidateCachedKey failures; change it so cache
invalidation errors are observable: after the DB update in resetKeyCostResetAt,
await invalidateCachedKey(result[0].key) and if it throws, log the error with
context (include keyId and key) and rethrow or return a distinct failure (do not
.catch(() => {})). This makes callers see a partial-failure (DB updated but
cache stale) so they can handle or retry; reference the functions
resetKeyCostResetAt and invalidateCachedKey when making the change.

@tesgth032

Copy link
Copy Markdown
Contributor

感谢这个 PR!整体方向我觉得很好:引入 keys.cost_reset_at + resolveKeyCostResetAt(MAX(key,user)),并把 Key 级别的限额检查/展示裁剪、以及“仅重置某个 Key 的限额累计”的管理动作/UI 串起来,覆盖面很完整 👍

这边 review 下来有几个点想提醒一下(语气偏建议,供参考):

  1. 阻断合并:迁移可能没有被执行,导致 CI API Tests 直接炸
  • CI 的 🌐 API Tests 里出现了 Postgres 报错:column "cost_reset_at" of relation "keys" does not exist,因此很多用户/Key 管理相关接口会返回 400。
  • 我看到新增了 drizzle/0083_aromatic_wolverine.sql,但 drizzle/meta/_journal.json 目前还停在 0082_*,看起来没有把 0083_aromatic_wolverine 注册进去。
  • 这会导致 AUTO_MIGRATE 场景下迁移未被实际执行(数据库没有新列),从而触发上述错误。
  • 建议:用 drizzle-kit 的标准流程重新生成/同步 migration(让 _journal.json 自动更新),或至少补齐 _journal.json 对 0083 的条目后再跑一遍 e2e。
  1. 小建议:migration SQL 文件末尾补一个换行
  • drizzle/0083_aromatic_wolverine.sql 目前是 No newline at end of file,建议补一下,避免某些工具链对 EOF 处理不一致。
  1. UX/可维护性:Reset Quota 按钮建议做 admin 显示 gating + 刷新方式优化
  • 现在服务端已经做了 admin 权限校验(很安全),但如果普通用户也能看到按钮,点了会提示无权限,体验上可能有点困惑;可以考虑只在 isAdmin 时展示。
  • 成功后用 router.refresh()/相关 query invalidate 代替 window.location.reload(),会更“轻”,也更符合 Next 的数据刷新模式。
  1. 建议补一条覆盖新能力的 E2E/API 用例(防回归)
  • 例如:制造一段 key usage → 调 keys.resetKeyLimitsOnly → 验证 key 侧 5h/daily/weekly/monthly/total 归零,但 usage logs 保留、user 侧累计不受影响。

整体上改动逻辑我觉得是对的,上面主要是把 CI 阻断点和一些可选优化点提出来,方便你快速收敛合并。

- Register migration 0083 in _journal.json (fixes CI AUTO_MIGRATE)
- Add trailing newline to migration SQL file
- Guard reset quota section with isAdmin check
- Replace window.location.reload() with router.refresh()
- Fix zh-TW: use traditional '每週' instead of simplified '每周'
@sususu98
sususu98 force-pushed the feat/key-cost-reset-v2 branch from 5ce72eb to a25cd69 Compare March 16, 2026 05:46

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/actions/keys.ts (1)

823-892: 建议补一条重置限额回归测试。

建议加 API/E2E 用例:先制造 key usage,再调用 resetKeyLimitsOnly,断言 key 维度的 5h/日/周/月/total 被正确 clip,而 user 维度累计不被错误重置。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/actions/keys.ts` around lines 823 - 892, 为 resetKeyLimitsOnly
增加回归测试:在测试中用 makeKey / createKeyUsage 或相应 helper 先制造对指定 key 的累计使用(覆盖
5h/日/周/月/total 边界),然后调用 resetKeyLimitsOnly(keyId) 并断言该 key 的限额相关字段(costResetAt
触发后对 key 维度的 5 小时、日、周、月、total 等累计值应被正确 clip/重置或回退到期望范围),同时断言同一用户下的 user
维度累计(userId 相关的累计/配额记录)没有被错误重置;测试应覆盖成功清理 Redis cache 的路径(mock/spy
clearSingleKeyCostCache)及 cache 清理失败的错误分支以验证日志/错误处理。
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/actions/keys.ts`:
- Around line 823-892: 为 resetKeyLimitsOnly 增加回归测试:在测试中用 makeKey /
createKeyUsage 或相应 helper 先制造对指定 key 的累计使用(覆盖 5h/日/周/月/total 边界),然后调用
resetKeyLimitsOnly(keyId) 并断言该 key 的限额相关字段(costResetAt 触发后对 key 维度的 5
小时、日、周、月、total 等累计值应被正确 clip/重置或回退到期望范围),同时断言同一用户下的 user 维度累计(userId
相关的累计/配额记录)没有被错误重置;测试应覆盖成功清理 Redis cache 的路径(mock/spy clearSingleKeyCostCache)及
cache 清理失败的错误分支以验证日志/错误处理。

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e60ade05-a4ee-4873-839e-b76c3ce3b0bc

📥 Commits

Reviewing files that changed from the base of the PR and between 5ce72eb and 9448774.

📒 Files selected for processing (7)
  • drizzle/0083_aromatic_wolverine.sql
  • drizzle/meta/_journal.json
  • messages/zh-TW/quota.json
  • src/actions/keys.ts
  • src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx
  • src/repository/_shared/transformers.ts
  • tests/integration/usage-ledger.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • drizzle/0083_aromatic_wolverine.sql
  • tests/integration/usage-ledger.test.ts

Comment on lines +379 to +381
current = await sumKeyTotalCost(options.keyHash, Infinity, options?.resetAt);
} else if (entityType === "user") {
current = await sumUserTotalCost(entityId, 365, options?.resetAt);
current = await sumUserTotalCost(entityId, Infinity, options?.resetAt);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behavior change: 365Infinity for total cost queries

All six sumKeyTotalCost / sumUserTotalCost calls previously passed 365 (days) as the look-back window, effectively capping total-cost accumulation at one year. Switching to Infinity is the correct fix for a "lifetime total" limit, but it is a breaking behavior change for existing deployments: any user or key with a limitTotalUsd who has more than 365 days of history will now see their total shoot up and may unexpectedly hit—or appear to already be over—their limit on the first request after upgrading.

Consider communicating this change in release notes or adding a migration note so operators can adjust their limits proactively.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/rate-limit/service.ts
Line: 379-381

Comment:
**Behavior change: `365``Infinity` for total cost queries**

All six `sumKeyTotalCost` / `sumUserTotalCost` calls previously passed `365` (days) as the look-back window, effectively capping total-cost accumulation at one year. Switching to `Infinity` is the correct fix for a "lifetime total" limit, but it is a **breaking behavior change for existing deployments**: any user or key with a `limitTotalUsd` who has more than 365 days of history will now see their total shoot up and may unexpectedly hit—or appear to already be over—their limit on the first request after upgrading.

Consider communicating this change in release notes or adding a migration note so operators can adjust their limits proactively.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/actions/keys.ts
Comment on lines +845 to +873
const updated = await resetKeyCostResetAt(keyId, new Date());
if (!updated) {
return {
ok: false,
error: tError("KEY_NOT_FOUND"),
errorCode: ERROR_CODES.NOT_FOUND,
};
}

try {
const { clearSingleKeyCostCache } = await import("@/lib/redis/cost-cache-cleanup");
const cacheResult = await clearSingleKeyCostCache({
keyId,
keyHash: key.key,
});
if (cacheResult) {
logger.info("Reset key limits only - Redis cost cache cleared", {
keyId,
userId: key.userId,
...cacheResult,
});
}
} catch (error) {
logger.error("Failed to clear Redis cache during key limits reset", {
keyId,
userId: key.userId,
error: error instanceof Error ? error.message : String(error),
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redis cost-cache cleared after DB commit, not atomically

The reset flow is:

  1. resetKeyCostResetAt(keyId, new Date()) — commits costResetAt = NOW to Postgres and invalidates the auth Redis cache.
  2. (Separately) clearSingleKeyCostCache(...) — scans and deletes rate-limit Redis keys.

Between steps 1 and 2 there is a window (typically a few milliseconds, but visible under load) during which a fresh request can read the updated costResetAt from the just-invalidated auth cache miss (DB read) and attempt rate-limit checks against the un-cleared Redis period counters. This means the rate-limiter may briefly see the new costResetAt clipping the time window while the underlying Redis counter still reflects the pre-reset accumulated value.

The PR description acknowledges "In-flight requests started before reset may still write costs after reset", but this is a slightly different scenario: it can also allow a request arriving after the reset to be incorrectly rate-limited by the old counter for a short window.

This is a minor eventual-consistency gap that is probably acceptable for a soft quota reset, but it should be documented with a comment near the clearSingleKeyCostCache call so future maintainers understand the design choice.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/actions/keys.ts
Line: 845-873

Comment:
**Redis cost-cache cleared after DB commit, not atomically**

The reset flow is:
1. `resetKeyCostResetAt(keyId, new Date())` — commits `costResetAt = NOW` to Postgres and invalidates the auth Redis cache.
2. (Separately) `clearSingleKeyCostCache(...)` — scans and deletes rate-limit Redis keys.

Between steps 1 and 2 there is a window (typically a few milliseconds, but visible under load) during which a fresh request can read the updated `costResetAt` from the just-invalidated auth cache miss (DB read) and attempt rate-limit checks against the **un-cleared** Redis period counters. This means the rate-limiter may briefly see the new `costResetAt` clipping the time window while the underlying Redis counter still reflects the pre-reset accumulated value.

The PR description acknowledges "In-flight requests started before reset may still write costs after reset", but this is a slightly different scenario: it can also allow a request arriving *after* the reset to be incorrectly rate-limited by the old counter for a short window.

This is a minor eventual-consistency gap that is probably acceptable for a soft quota reset, but it should be documented with a comment near the `clearSingleKeyCostCache` call so future maintainers understand the design choice.

How can I resolve this? If you propose a fix, please make it concise.

@ding113
ding113 merged commit 8d578a0 into ding113:dev Mar 16, 2026
3 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in Claude Code Hub Roadmap Mar 16, 2026
@github-actions github-actions Bot mentioned this pull request Mar 18, 2026
4 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core area:i18n area:UI enhancement New feature or request size/XL Extra Large PR (> 1000 lines)

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants