Skip to content

feat: add temporary key groups and self-service quota windows#1106

Open
dofastted wants to merge 15 commits into
ding113:devfrom
dofastted:finish/tempkeys-quota-status-upstream-main-20260425
Open

feat: add temporary key groups and self-service quota windows#1106
dofastted wants to merge 15 commits into
ding113:devfrom
dofastted:finish/tempkeys-quota-status-upstream-main-20260425

Conversation

@dofastted
Copy link
Copy Markdown
Contributor

@dofastted dofastted commented Apr 25, 2026

Summary

Add temporary key group batch management, self-service quota windows with precomputed remaining fields, user-to-key config sync, readonly Bearer API key access for quota endpoints, global cost multiplier correction, and status route aliases. Also includes a zero-downtime rollout script and Docker health check improvements.

Changes

1. Temporary Key Groups

Batch-create, download, and remove groups of temporary API keys that inherit routing and limits from a base key. Each group is tagged by temporary_group_name (derived from the user's provider group) for lifecycle management.

  • New lib module: src/lib/keys/temporary-key-groups.ts -- group name resolution, batch payload builder, limit validation
  • New actions in src/actions/keys.ts: createTemporaryKeysBatch, removeTemporaryKeyGroup, downloadTemporaryKeyGroup
  • New repository functions: createKeysBatch, deleteKeysBatch for bulk key operations
  • New UI component: temporary-key-batch-dialog.tsx with creation dialog, group display, and download
  • Updated UI: user-key-table-row.tsx reorganized into standard/temporary key sections
  • DB migration (0099): adds temporary_group_name column on keys with partial index idx_keys_user_temporary_group
  • Docs: docs/examples/temporary-key-groups.md with curl examples for all three operations

2. Self-Service Quota Windows

The getMyQuota endpoint now returns precomputed quotaWindows (5h, daily, weekly, monthly, total) with limitUsd, usedUsd, remainingUsd, usedPercent, remainingPercent, isUnlimited, and isExhausted per window. Overall remaining and remainingPercent fields are the minimum across all bounded windows.

  • New interface: MyUsageQuotaWindow with period-scoped computed fields
  • New helpers: resolveEffectiveQuotaWindow, buildQuotaWindow, resolveTotalLimitWithMonthlyFallback
  • Quota extractor examples (6 variants): direct, compatible, daily, weekly, monthly, total -- all callable via Bearer API key
  • Readonly redaction: src/lib/my-usage/readonly-redaction.ts strips sensitive fields from quota/logs when called with a readonly Bearer key

3. User-to-Key Config Sync

Admins can propagate user-level settings (daily/weekly/monthly/total limits, concurrent sessions, provider group, daily reset mode/time) to all undeleted keys for one or more users in a single operation.

  • New lib module: src/lib/users/user-key-sync.ts -- buildSyncedKeyConfigs, buildFirstSyncedKeyConfig
  • New actions in src/actions/users.ts: syncUserConfigToKeys (single user), batchSyncUserConfigToKeys (batch)
  • New user creation now syncs all user limits to the default key instead of only setting provider_group
  • Batch edit dialog updated with "Sync to Keys" toggle and sequential execution logic

4. Readonly Bearer API Key Access

Endpoints using allowReadOnlyAccess now accept Bearer API keys even when the proxy is in opaque session-token mode, enabling self-service quota APIs without requiring a login cookie.

  • New module: src/lib/auth/readonly-access.ts
  • New module: src/lib/auth/public-path-policy.ts for public route handling
  • Modified: src/app/v1/_lib/proxy/auth-guard.ts accepts Bearer keys in opaque auth mode

5. Global Cost Multiplier Correction

New system setting cost_multiplier_correction (default 0) that is added to every provider's cost multiplier at runtime. Useful for global billing adjustments without editing each provider individually.

  • New lib module: src/lib/billing/cost-multiplier.ts
  • DB migration: adds cost_multiplier_correction column on system_settings (default 0, NOT NULL)
  • Settings UI: new "Billing and Rate Correction" section in system settings form
  • Applied in: src/app/v1/_lib/proxy/provider-selector.ts

6. Status Route Aliases

Adds /api/status and dashboard header link to the existing public system status page, preserving the current /api/system-status implementation.

  • New route: src/app/api/status/route.ts (alias)
  • Dashboard header: "System Status" navigation link

7. Infrastructure & DevOps

  • New script: scripts/server-zero-downtime-rollout.sh -- blue-green Docker rollout with nginx cutover, backup/rollback, and dry-run support
  • Dockerfile: added curl package for health checks
  • Health checks: replaced curl with node -e fetch(...) in all compose files and deploy scripts (removes curl dependency)
  • Environment: added NODE_OPTIONS=--max-old-space-size=1536, HOST=0.0.0.0, HOSTNAME=0.0.0.0
  • .gitignore: added .runtime/ directory

8. i18n

All 5 languages (en, zh-CN, zh-TW, ja, ru) updated with strings for temporary keys, sync-to-keys, and billing correction sections.

Database Migration

Table Column Type Default Notes
keys temporary_group_name varchar(120) NULL Nullable, partial index on (user_id, temporary_group_name) WHERE deleted_at IS NULL AND temporary_group_name IS NOT NULL
system_settings cost_multiplier_correction numeric(10,4) 0 NOT NULL, runtime additive to provider cost multiplier

Migration file: drizzle/0099_powerful_micromax.sql

Breaking Changes

None. All new columns are nullable or have safe defaults. New response fields on getMyQuota are additive -- existing consumers are unaffected.

Testing

Automated Tests (8 new test files)

  • tests/unit/actions/temporary-keys.test.ts -- temporary key batch create/remove/download
  • tests/unit/actions/users-key-sync.test.ts -- user-to-key sync actions
  • tests/unit/lib/keys/temporary-key-groups.test.ts -- group name resolution, payload building
  • tests/unit/lib/users/user-key-sync.test.ts -- config distribution logic
  • tests/unit/lib/billing/cost-multiplier.test.ts -- multiplier correction
  • tests/unit/proxy/auth-guard.test.ts -- Bearer key in opaque auth mode
  • tests/unit/auth/auth-scoped-session-branches.test.ts -- session branch coverage
  • tests/unit/api/status-route-alias.test.ts -- status route alias

Updated Tests

  • tests/api/my-usage-readonly.test.ts -- readonly Bearer quota regression
  • tests/unit/actions/my-usage-concurrent-inherit.test.ts -- quota window inheritance
  • tests/unit/user-dialogs.test.tsx -- temporary key dialog rendering
  • tests/api/api-actions-integrity.test.ts -- new action registration

Verification

  • npm run typecheck
  • npm run build
  • npm run db:generate
  • node scripts/validate-migrations.js
  • node --check quota extractor examples
  • diff docs/public quota extractor examples
  • vitest targeted unit set
  • test:coverage:my-usage with DSN=postgres://postgres:postgres@127.0.0.1:5432/claude_code_hub_test

Note

  • npm run lint still fails on existing repository-wide Biome version/format issues, including biome.json schema version mismatch and CRLF formatting noise.

Description enhanced by Claude AI

mci777 and others added 15 commits April 25, 2026 20:50
Enable allowReadOnlyAccess endpoints to accept Bearer API keys even when session tokens are opaque, so quota-style self-service APIs no longer require a login cookie first.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Return template-friendly quota fields from getMyQuota, including precomputed remaining values and a Bearer-only integration regression test for the direct API-key flow.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Add a local example script that calls the Bearer API-key quota endpoint and normalizes the JSON into template-friendly balance fields for third-party integrations.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Replace the previous Node-script example with the actual third-party template DSL format and point it at the verified getMyQuota Bearer endpoint.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 25, 2026

📝 Walkthrough

功能概览

添加临时密钥分组功能(包括批量创建、删除、下载临时API密钥),引入成本乘数校正系统设置,实现用户配置同步到密钥,增强配额/使用量追踪窗口,以及支持多语言的用户界面更新。

变更清单

变更分组 / 文件 变更摘要
环境与部署配置
.env.example, .gitignore, Dockerfile, dev/docker-compose.yaml, docker-compose.yaml, scripts/deploy.ps1, scripts/deploy.sh, scripts/server-zero-downtime-rollout.sh
新增 NODE_OPTIONS 环保变量配置堆内存限制、.runtime/ 目录忽略规则、生产容器 curl 安装步骤、健康检查逻辑从 curl 迁移到 Node.js fetch(支持 127.0.0.1 和 0.0.0.0 HOST/HOSTNAME)、零停机时间灰度发布脚本(包含 nginx 切换、健康检查、备份恢复)。
数据库架构
drizzle/0099_powerful_micromax.sql, drizzle/meta/_journal.json, src/drizzle/schema.ts
新增 keystemporaryGroupName 字段和复合索引、systemSettingscostMultiplierCorrection 字段(默认值 0)。
服务器操作与认证
src/actions/keys.ts, src/actions/users.ts, src/actions/my-usage.ts, src/actions/system-config.ts, src/lib/auth.ts, src/lib/auth/readonly-access.ts, src/lib/auth/public-path-policy.ts, src/app/v1/_lib/proxy/auth-guard.ts, src/app/v1/_lib/proxy/session.ts
添加临时密钥批量创建/删除/下载、用户配置同步到密钥(单个和批量)、配额计算扩展(包括 5h/日/周/月窗口)、只读访问 gate 处理、认证状态追踪 readonly 标志。
密钥与用户管理库
src/lib/keys/temporary-key-groups.ts, src/lib/users/user-key-sync.ts, src/lib/billing/cost-multiplier.ts, src/repository/key.ts, src/repository/users.ts, src/repository/system-config.ts, src/repository/_shared/transformers.ts
实现临时密钥分组验证、命名、序列解析、批量创建负载生成;用户配置同步字段分配;成本乘数校正计算与应用;密钥批量操作(创建/删除);系统设置持久化与变换。
系统配置与缓存
src/lib/config/system-settings-cache.ts, src/lib/validation/schemas.ts, src/types/system-config.ts, src/types/key.ts, src/types/user.ts
新增 costMultiplierCorrection 类型定义、验证约束(-100 到 100)、默认值初始化;扩展 KeyUser 类型以支持 temporaryGroupName
配额与使用量处理
src/lib/my-usage/readonly-redaction.ts, src/app/v1/_lib/proxy/provider-selector.ts
新增只读配额数据截断(隐藏允许的模型/客户端);日志端点隐藏;成本乘数校正全局应用。
API 路由与端点
src/app/api/actions/[...route]/route.ts, src/app/api/admin/system-config/route.ts, src/app/api/status/route.ts, src/proxy.ts
扩展 OpenAPI 架构支持临时密钥、用户同步、重置端点;新增 /api/status 别名路由;公开路径匹配重构使用默认路由列表。
仪表盘 UI 组件
src/app/[locale]/dashboard/_components/dashboard-header.tsx, src/app/[locale]/dashboard/_components/user/user-management-table.tsx, src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx, src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx, src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx, src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx, src/app/[locale]/dashboard/_components/user/batch-edit/batch-user-section.tsx, src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx, src/app/[locale]/dashboard/users/users-page-client.tsx, src/app/[locale]/dashboard/_components/user/temporary-key-batch-dialog.tsx
新增系统状态导航项、临时密钥批量创建对话框、密钥分组展示(下载/删除操作)、用户编辑/批量编辑中的"同步到密钥"功能、KeyEditSection 条件化显示控制。
设置表单
src/app/[locale]/settings/config/_components/system-settings-form.tsx, src/app/[locale]/settings/config/page.tsx
新增成本乘数校正数值输入、整合账单相关配置卡片、快速链接到新设置区域。
国际化消息
messages/en/dashboard.json, messages/en/settings/config.json, messages/ja/dashboard.json, messages/ja/settings/config.json, messages/ru/dashboard.json, messages/ru/settings/config.json, messages/zh-CN/dashboard.json, messages/zh-CN/settings/config.json, messages/zh-TW/dashboard.json, messages/zh-TW/settings/config.json
新增系统状态、临时密钥、密钥同步、账单校正相关 UI 文案(英、日、俄、简体中文、繁体中文)。
文档与示例
docs/examples/temporary-key-groups.md, docs/examples/api-key-quota-extractor*.js, public/examples/api-key-quota-extractor*.js, README.md, README.en.md, plan/2026-04-25_18-53-35-cch.md
新增临时密钥分组 API 命令行文档、多种配额提取器示例(兼容/直接/日/周/总)、项目计划文档。
单元与集成测试
tests/api/api-actions-integrity.test.ts, tests/api/my-usage-readonly.test.ts, tests/configs/my-usage.config.ts, tests/unit/actions/temporary-keys.test.ts, tests/unit/actions/users-key-sync.test.ts, tests/unit/actions/users-reset-all-statistics.test.ts, tests/unit/actions/my-usage-concurrent-inherit.test.ts, tests/unit/auth/auth-scoped-session-branches.test.ts, tests/unit/api/status-route-alias.test.ts, tests/unit/lib/billing/cost-multiplier.test.ts, tests/unit/lib/config/system-settings-cache.test.ts, tests/unit/lib/keys/temporary-key-groups.test.ts, tests/unit/lib/users/user-key-sync.test.ts, tests/unit/proxy/auth-guard.test.ts, tests/unit/proxy/proxy-auth-cookie-passthrough.test.ts, tests/unit/user-dialogs.test.tsx, tests/vitest.base.ts
扩展 API 端点完整性检查、只读访问配额验证、临时密钥操作行为、用户密钥同步、成本乘数校正、状态路由别名、认证会话分支、测试超时配置。

代码审查难度估计

🎯 4 (复杂) | ⏱️ ~60 分钟

可能相关的 PR

  • PR #960 - 系统设置管道(DB 架构、仓储变换、缓存、验证、类型、设置 UI)扩展,添加新设置字段相同代码路径。
  • PR #1081 - 公开状态 API 表面修改,包括 /api/status 别名路由和代理头处理。
  • PR #1063 - 配额/使用量操作端点与只读访问处理重叠,涉及 getMyQuota 和日志隐藏逻辑。
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.54% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed 拉取请求标题准确反映了主要变更内容——添加临时密钥组和自服务配额窗口功能
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The pull request description is comprehensive and directly relevant to the changeset, covering all major features (temporary key groups, quota windows, config sync, readonly access, cost multiplier correction, status aliases, and infrastructure improvements).

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@github-actions github-actions Bot added size/XL Extra Large PR (> 1000 lines) enhancement New feature or request area:core area:UI area:i18n labels Apr 25, 2026
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

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 introduces significant new features (temporary key groups, self-service quota windows, global cost multiplier correction) while preserving backward compatibility. The PR is XL-sized with 10,971 additions across 92 files. Due to the size, I focused on the critical path: schema changes, actions, proxy layer, repository layer, and system settings cache.

PR Size: XL

  • Lines changed: 11,263 (10,971 additions + 292 deletions)
  • Files changed: 92
  • Recommendation: Consider splitting future PRs of this size. This PR touches database schema, actions, proxy layer, UI components, repository layer, and tests. The core schema changes (temporary key groups) could be separated from the quota windows and cost multiplier changes.

Issues Found

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

High Priority Issues (Should Fix)

[HIGH] [ERROR-SILENT] Silent error swallowing in key repository
File: src/repository/key.ts (around line 191-196, 213-217)
Multiple try/catch blocks use empty catch handlers:

  • Line 194-196: } catch { // ignore } for apiKeyVacuumFilter.noteExistingKey()
  • Lines 204, 209, 214: .catch(() => {}) for Redis cache operations

These silent failures make debugging difficult when vacuum filter or Redis operations fail. While these are "best effort" operations, at minimum log debug-level messages.

Suggested fix:

try {
  apiKeyVacuumFilter.noteExistingKey(created.key);
} catch (error) {
  logger.debug("[KeyRepository] Vacuum filter note failed", { error });
  // Non-critical: continue without failing
}

Medium Priority Issues (Consider Fixing)

[MEDIUM] [STANDARD-VIOLATION] i18n not used for error messages
File: src/actions/keys.ts lines 142, 418, 454, 697
Hardcoded Chinese strings:

  • Line 142: "用户不存在"
  • Line 418: "密钥不存在"
  • Line 697: "密钥不存在"

Per CLAUDE.md rule #3: "All user-facing strings must use i18n (5 languages supported). Never hardcode display text."

Suggested fix: Use the tError translation function already imported in these functions.

[MEDIUM] [COMMENT-INACCURATE] Comment contradicts behavior
File: src/lib/config/system-settings-cache.ts lines 62-63

// 安全敏感开关:冷缓存 / DB 读取失败时 fail-closed,避免意外重新开启跨供应商 raw fallback。
allowNonConversationEndpointProviderFallback: false,

The comment says "fail-closed" but the behavior is actually "fail-open" (returns false for safety). The correct security terminology is "fail-closed" means to deny/close access when failing. The code does this correctly, but the comment is misleading.

Suggested fix:

// Security-sensitive: fail-closed (deny) when cache is cold or DB is unavailable

[MEDIUM] [TYPE-ANY-USAGE] Explicit 'any' type bypass
File: src/repository/key.ts line 227

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const dbData: any = { updatedAt: new Date() };

Consider using Partial<Record<string, unknown>> or defining a proper update type.

[MEDIUM] [TEST-MISSING-CRITICAL] Missing unit tests for new features
The PR adds temporary key groups functionality but tests/unit/lib/keys/ directory does not exist. The PR body mentions tests/unit/actions/temporary-keys.test.ts but this file wasn't found in the file list. Please verify test coverage for:

  • Temporary key group creation/deletion
  • Group-based key operations
  • Cost multiplier correction logic

Review Coverage

  • Logic and correctness - Reviewed schema, actions, proxy layer
  • Security (OWASP Top 10) - No injection risks found; auth checks present
  • Error handling - Identified silent failure patterns
  • Type safety - Noted one any bypass
  • Documentation accuracy - Found one misleading comment
  • Test coverage - Flagged potential gap
  • Code clarity - Good overall structure

Positive Observations

  1. Good error handling in auth layer: src/app/v1/_lib/proxy/auth-guard.ts properly logs and categorizes authentication failures.
  2. Systematic cache fallback: src/lib/config/system-settings-cache.ts properly implements fail-soft behavior with stale cache fallback.
  3. Comprehensive schema definitions: New fields in schema.ts have proper defaults and indexes.
  4. i18n consistency: Most new UI strings use next-intl translations (5 languages).

Automated review by Claude AI

Copy link
Copy Markdown
Contributor

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

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 temporary key groups and user-to-key configuration synchronization, including UI components for batch key generation and sync, database schema updates, and a zero-downtime rollout script. My review identified critical issues in the key limit distribution logic, specifically regarding null handling and remainder distribution, as well as performance concerns regarding sequential database updates and Redis invalidations in batch operations. I have provided suggestions to improve the distribution logic and recommended batching database and cache operations to ensure scalability.

Comment on lines +102 to +125
function distributeConcurrentSessions(
value: number | string | null | undefined,
keyCount: number
): { values: number[]; discarded: number } {
if (keyCount <= 0) return { values: [], discarded: 0 };

const total = toFiniteNumber(value);
const normalizedTotal = total === null ? 0 : Math.floor(total);
if (normalizedTotal <= 0) {
return { values: Array.from({ length: keyCount }, () => 0), discarded: 0 };
}

if (normalizedTotal >= keyCount) {
const perKey = Math.floor(normalizedTotal / keyCount);
return {
values: Array.from({ length: keyCount }, () => perKey),
discarded: normalizedTotal - perKey * keyCount,
};
}

return {
values: Array.from({ length: keyCount }, (_, index) => (index < normalizedTotal ? 1 : 0)),
discarded: 0,
};
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.

critical

This function incorrectly handles null values by converting them to 0, which blocks access for users with no concurrent session limit. It also discards the remainder during distribution. The logic should preserve null and distribute the remainder.

function distributeConcurrentSessions(
  value: number | string | null | undefined,
  keyCount: number
): { values: Array<number | null>; discarded: number } {
  if (keyCount <= 0) return { values: [], discarded: 0 };

  const total = toFiniteNumber(value);
  if (total === null) {
    return { values: Array.from({ length: keyCount }, () => null), discarded: 0 };
  }

  const normalizedTotal = Math.max(0, Math.floor(total));
  const perKey = Math.floor(normalizedTotal / keyCount);
  const remainder = normalizedTotal % keyCount;

  return {
    values: Array.from({ length: keyCount }, (_, i) => perKey + (i < remainder ? 1 : 0)),
    discarded: 0,
  };
}

const configs = Array.from({ length: normalizedKeyCount }, (_, index) => ({
limit5hUsd: amountByField.limit5hUsd.values[index] ?? null,
limitDailyUsd: amountByField.limitDailyUsd.values[index] ?? null,
limitWeeklyUsd: amountByField.limitWeeklyUsd.values[index] ?? null,
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.

critical

Remove the nullish coalescing operator to allow null values (no limit) to be correctly assigned to keys. Using ?? 0 would block access when the limit is intended to be unlimited.

Suggested change
limitWeeklyUsd: amountByField.limitWeeklyUsd.values[index] ?? null,
limitConcurrentSessions: concurrent.values[index],

limitWeeklyUsd: number | null;
limitMonthlyUsd: number | null;
limitTotalUsd: number | null;
limitConcurrentSessions: number;
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

The limitConcurrentSessions field should be nullable to correctly represent the absence of a limit (inheritance from user or unlimited).

Suggested change
limitConcurrentSessions: number;
limitConcurrentSessions: number | null;

Comment on lines +76 to +99
const amount = toFiniteNumber(value);
if (amount === null || amount <= 0) {
return { values: Array.from({ length: keyCount }, () => null), discarded: 0 };
}

const totalCents = Math.round(amount * 100);
if (totalCents <= 0) {
return { values: Array.from({ length: keyCount }, () => null), discarded: 0 };
}

if (totalCents >= keyCount) {
const perKeyCents = Math.floor(totalCents / keyCount);
return {
values: Array.from({ length: keyCount }, () => centsToUsd(perKeyCents)),
discarded: centsToUsd(totalCents - perKeyCents * keyCount),
};
}

return {
values: Array.from({ length: keyCount }, (_, index) =>
index < totalCents ? centsToUsd(1) : null
),
discarded: 0,
};
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

The distribution logic for amounts discards the remainder and uses null for keys that don't receive a portion of a small limit. This can cause those keys to inherit the full user limit instead of being restricted. It's better to distribute the remainder among the keys and use 0 for the rest.

Suggested change
const amount = toFiniteNumber(value);
if (amount === null || amount <= 0) {
return { values: Array.from({ length: keyCount }, () => null), discarded: 0 };
}
const totalCents = Math.round(amount * 100);
if (totalCents <= 0) {
return { values: Array.from({ length: keyCount }, () => null), discarded: 0 };
}
if (totalCents >= keyCount) {
const perKeyCents = Math.floor(totalCents / keyCount);
return {
values: Array.from({ length: keyCount }, () => centsToUsd(perKeyCents)),
discarded: centsToUsd(totalCents - perKeyCents * keyCount),
};
}
return {
values: Array.from({ length: keyCount }, (_, index) =>
index < totalCents ? centsToUsd(1) : null
),
discarded: 0,
};
const amount = toFiniteNumber(value);
if (amount === null) {
return { values: Array.from({ length: keyCount }, () => null), discarded: 0 };
}
if (amount <= 0) {
return { values: Array.from({ length: keyCount }, () => 0), discarded: 0 };
}
const totalCents = Math.round(amount * 100);
const perKeyCents = Math.floor(totalCents / keyCount);
const remainderCents = totalCents % keyCount;
return {
values: Array.from({ length: keyCount }, (_, i) =>
centsToUsd(perKeyCents + (i < remainderCents ? 1 : 0))
),
discarded: 0,
};

Comment thread src/actions/users.ts
Comment on lines +1425 to +1441
.update(keysTable)
.set({
updatedAt: new Date(),
limit5hUsd: config.limit5hUsd === null ? null : config.limit5hUsd.toString(),
limitDailyUsd: config.limitDailyUsd === null ? null : config.limitDailyUsd.toString(),
dailyResetMode: config.dailyResetMode,
dailyResetTime: config.dailyResetTime,
limitWeeklyUsd:
config.limitWeeklyUsd === null ? null : config.limitWeeklyUsd.toString(),
limitMonthlyUsd:
config.limitMonthlyUsd === null ? null : config.limitMonthlyUsd.toString(),
limitTotalUsd: config.limitTotalUsd === null ? null : config.limitTotalUsd.toString(),
limitConcurrentSessions: config.limitConcurrentSessions,
providerGroup: config.providerGroup,
})
.where(and(eq(keysTable.id, keyRow.id), isNull(keysTable.deletedAt)));
}
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

Performing individual database updates for every key in a loop inside a transaction is inefficient. With a batch size of 500 users, this could result in thousands of sequential queries. Consider optimizing by grouping keys that receive the same configuration and updating them in a single query per user or batch.

Comment thread src/actions/users.ts
Comment on lines +1460 to +1463
await Promise.all([
...requestedIds.map((userId) => invalidateCachedUser(userId).catch(() => {})),
...keyStringsForCache.map((key) => invalidateCachedKey(key).catch(() => {})),
]);
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

Executing a large number of individual Redis invalidations in Promise.all can overwhelm the Redis server or the Node.js event loop. It is recommended to use a batched invalidation approach (e.g., using a Redis pipeline or a single DEL command with multiple keys).

Comment on lines +494 to +495
-e DSN="$dsn" \
-e REDIS_URL="$redis_url" \
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

The DSN and REDIS_URL are constructed using hardcoded hostnames (postgres, redis). This ignores the actual connection settings in the .env file and assumes a specific Docker network setup. It's better to use the DSN and REDIS_URL variables directly from the environment if they are defined.

Comment on lines +94 to +99
return {
values: Array.from({ length: keyCount }, (_, index) =>
index < totalCents ? centsToUsd(1) : null
),
discarded: 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.

P1 Sparse-budget keys get null (unlimited) instead of zero

When totalCents < keyCount (e.g. distributing $0.50 across 100 keys), the keys that don't receive a $0.01 share are assigned null, which this system treats as "no limit" (unlimited). Those keys will have unrestricted spending, the opposite of the administrator's intent. distributeConcurrentSessions correctly uses 0 for the underfunded slots; the same pattern should apply here — either use 0 or leave those keys out of the update instead of setting them to unlimited.

Suggested change
return {
values: Array.from({ length: keyCount }, (_, index) =>
index < totalCents ? centsToUsd(1) : null
),
discarded: 0,
};
return {
values: Array.from({ length: keyCount }, (_, index) =>
index < totalCents ? centsToUsd(1) : centsToUsd(0)
),
discarded: 0,
};
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/users/user-key-sync.ts
Line: 94-99

Comment:
**Sparse-budget keys get `null` (unlimited) instead of zero**

When `totalCents < keyCount` (e.g. distributing $0.50 across 100 keys), the keys that don't receive a $0.01 share are assigned `null`, which this system treats as "no limit" (unlimited). Those keys will have unrestricted spending, the opposite of the administrator's intent. `distributeConcurrentSessions` correctly uses `0` for the underfunded slots; the same pattern should apply here — either use `0` or leave those keys out of the update instead of setting them to unlimited.

```suggestion
  return {
    values: Array.from({ length: keyCount }, (_, index) =>
      index < totalCents ? centsToUsd(1) : centsToUsd(0)
    ),
    discarded: 0,
  };
```

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

Comment thread src/actions/keys.ts
Comment on lines +872 to +878
return {
ok: false,
error: tError("PERMISSION_DENIED"),
errorCode: ERROR_CODES.PERMISSION_DENIED,
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Hardcoded Chinese error strings bypass i18n

Several error messages in createTemporaryKeysBatch, removeTemporaryKeyGroup, and downloadTemporaryKeyGroup are hardcoded in Chinese (e.g. "单次最多生成 ${TEMPORARY_KEY_BATCH_MAX_COUNT} 个临时 Key", "临时分组名称不能为空", "临时 Key 分组不存在"). The rest of the codebase uses tError(...) from next-intl for user-facing error messages. These strings should be added to the message files and accessed through the translation function to stay consistent with the multi-language setup.

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

Comment:
**Hardcoded Chinese error strings bypass i18n**

Several error messages in `createTemporaryKeysBatch`, `removeTemporaryKeyGroup`, and `downloadTemporaryKeyGroup` are hardcoded in Chinese (e.g. `"单次最多生成 ${TEMPORARY_KEY_BATCH_MAX_COUNT} 个临时 Key"`, `"临时分组名称不能为空"`, `"临时 Key 分组不存在"`). The rest of the codebase uses `tError(...)` from `next-intl` for user-facing error messages. These strings should be added to the message files and accessed through the translation function to stay consistent with the multi-language setup.

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

Comment thread src/lib/auth.ts
Comment on lines 295 to +298
return validateKey(token, options);
}

if (options?.allowReadOnlyAccess && tokenKind === "legacy") {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Legacy token with allowReadOnlyAccess now bypasses ADMIN_TOKEN check

In legacy session mode, the new block short-circuits before the ADMIN_TOKEN equality check when allowReadOnlyAccess is true and the token kind is "legacy". Since a bare API-key string would also be detected as "legacy" kind, verify this doesn't prevent legitimate admin-token use in readonly contexts (e.g. /api/status behind readonly middleware).

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/auth.ts
Line: 295-298

Comment:
**Legacy token with `allowReadOnlyAccess` now bypasses `ADMIN_TOKEN` check**

In `legacy` session mode, the new block short-circuits before the `ADMIN_TOKEN` equality check when `allowReadOnlyAccess` is true and the token kind is `"legacy"`. Since a bare API-key string would also be detected as `"legacy"` kind, verify this doesn't prevent legitimate admin-token use in readonly contexts (e.g. `/api/status` behind readonly middleware).

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

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ae813781df

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/repository/key.ts
Comment on lines +500 to +503
canLoginWebUi: keyData.can_login_web_ui ?? true,
limit5hUsd: keyData.limit_5h_usd != null ? keyData.limit_5h_usd.toString() : null,
limitDailyUsd: keyData.limit_daily_usd != null ? keyData.limit_daily_usd.toString() : null,
dailyResetMode: keyData.daily_reset_mode ?? "fixed",
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve 5h reset mode when creating key batches

createKeysBatch does not map limit_5h_reset_mode into inserted rows, so batch-created keys fall back to the DB default reset mode (rolling) instead of inheriting the caller-provided mode. This changes quota-window behavior for temporary keys cloned from a fixed base key and can make usage enforcement inconsistent with the source key.

Useful? React with 👍 / 👎.

Comment on lines +449 to +452
requestSchema: z.object({
userId: z.number().int().positive(),
...UpdateUserSchema.shape,
}),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restrict syncUserConfigToKeys to actually persisted fields

The route accepts the entire UpdateUserSchema, but syncUserConfigToKeys only persists a subset through buildUserDbUpdates (for example, limit5hResetMode is accepted yet not written). This creates silent no-op updates where API callers get success but requested fields are ignored; the request schema should match supported sync fields (as done in the batch sync route) or the action should persist every accepted field.

Useful? React with 👍 / 👎.

Comment thread src/actions/keys.ts
Comment on lines +881 to +884
return {
ok: false,
error: `单次最多生成 ${TEMPORARY_KEY_BATCH_MAX_COUNT} 个临时 Key`,
errorCode: ERROR_CODES.INVALID_FORMAT,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Replace hardcoded temporary-key errors with i18n keys

This new user-facing error message is hardcoded Chinese text, and similar literals were added in the same temporary-key actions, so non-Chinese locales will receive untranslated responses. That violates the repository rule in /workspace/claude-code-hub/AGENTS.md requiring i18n for all user-facing strings and introduces a localization regression.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 11

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

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

⚠️ Outside diff range comments (5)
messages/ru/settings/config.json (1)

45-99: ⚠️ Potential issue | 🟠 Major

多个语言文件中存在重复键。

publicStatusProjectionWarningpublicStatusBackgroundRefreshPending 在多个 locale 的 settings/config.json 中重复定义:

  • messages/ru/settings/config.json(第 45–46 行 vs 第 98–99 行)
  • messages/zh-TW/settings/config.json
  • messages/ja/settings/config.json

JSON 中重复键属未定义行为,多数解析器采用「后者覆盖前者」,但会导致静默数据丢失和维护混乱。需删除重复项(建议保留靠近 saveSuccess 等保存相关键的版本)。

建议修复示例(ru 文件)
     "configUpdated": "Параметры системы обновлены. Страница обновится для применения изменений валюты.",
-    "publicStatusProjectionWarning": "Системные настройки сохранены, но Redis-проекция public status не была обновлена.",
-    "publicStatusBackgroundRefreshPending": "Системные настройки сохранены, но публичная статус-страница может временно показывать устаревшие данные, пока фоновое обновление не завершится.",
     "currencies": {

同样的修复需要应用到 zh-TW 和 ja 文件。

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

In `@messages/ru/settings/config.json` around lines 45 - 99, Two keys,
"publicStatusProjectionWarning" and "publicStatusBackgroundRefreshPending", are
duplicated in the locale config (they appear twice in the same JSON); remove the
earlier duplicate occurrences and keep the copy adjacent to the save-related
keys (near "saveSuccess"/"saveSettings") so only one definition remains. Apply
the same change to the other locale config files that show the same duplication
(the zh-TW and ja locale config JSONs) to ensure each locale has a single
definition for those two keys.
src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx (1)

175-205: ⚠️ Potential issue | 🟡 Minor

limit5hResetMode 的独立处理补充说明注释

验证确认:buildFirstSyncedKeyConfig() 返回的 SyncedKeyConfig 接口中不存在 limit5hResetMode 字段,且输入的 UserKeySyncSource 接口也未定义此字段。相比之下,dailyResetModedailyResetTime 被完整纳入同步机制(行 183-184 传入,行 199-200 从 firstKeyConfig 读取),而 limit5hResetMode 却未传入 buildFirstSyncedKeyConfig(),第 197 行直接使用 data.user.limit5hResetMode ?? data.key.limit5hResetMode

这说明 limit5hResetMode 是有意设计为仅在 key 级别独立配置,不同步于用户级别设置。请在第 197 行旁添加一行注释说明这个设计决策(例如"limit5hResetMode 不通过 buildFirstSyncedKeyConfig 同步,保持为 key 独有配置"),避免后续维护者误认为这是遗漏。

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

In `@src/app/`[locale]/dashboard/_components/user/create-user-dialog.tsx around
lines 175 - 205, Add a brief inline comment next to the limit5hResetMode
assignment in the addKey call explaining that limit5hResetMode is intentionally
not part of buildFirstSyncedKeyConfig and is a key-level-only setting (e.g.
"limit5hResetMode 不通过 buildFirstSyncedKeyConfig 同步,保持为 key 独有配置"), so future
maintainers know this is deliberate; locate this change around the addKey(...)
call where limit5hResetMode is set (referencing buildFirstSyncedKeyConfig,
limit5hResetMode, data.user.limit5hResetMode, and data.key.limit5hResetMode).
messages/ru/dashboard.json (1)

1737-1779: ⚠️ Potential issue | 🟡 Minor

部分同步键文案未翻译为俄语

batchEdit.confirm.syncKeysbatchEdit.confirm.syncKeysDescriptionbatchEdit.toast.keysSyncedbatchEdit.toast.syncFailedbatchEdit.user.fields.syncKeysbatchEdit.user.placeholders.syncKeysDescription 这几个面向用户的字符串仍是英文,破坏了俄语界面的一致性。

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"。请补齐对应的俄语翻译,与其它 4 种语言保持一致。

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

In `@messages/ru/dashboard.json` around lines 1737 - 1779, The following
user-facing i18n keys are still in English and must be translated into Russian
to match other locales: batchEdit.confirm.syncKeys,
batchEdit.confirm.syncKeysDescription, batchEdit.toast.keysSynced,
batchEdit.toast.syncFailed, batchEdit.user.fields.syncKeys,
batchEdit.user.placeholders.syncKeysDescription; update the values for these
keys in messages/ru/dashboard.json with Russian equivalents consistent with the
other four locales (use the corresponding text from en/zh-CN/zh-TW/ja as
reference) and ensure interpolation tokens ({users}, {keys}, {error}, etc.) are
preserved exactly.
messages/ja/dashboard.json (1)

1622-1775: ⚠️ Potential issue | 🟠 Major

这批 ja 文案里还混入了多处英文,日语界面会直接漏翻。

这里新增的同步/临时 Key 流程里仍有明显英文文案,比如 Sync to KeysThis will overwrite...{count} keysTemp 等。这样上线后日语用户会在新功能里直接看到半英半日的界面。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 `@messages/ja/dashboard.json` around lines 1622 - 1775, Several user-facing
strings in the Japanese messages are still in English (e.g., "Sync to Keys",
"This will overwrite...", "Temp", "{count} keys"); update the i18n entries to
full Japanese translations and avoid hardcoded English. Specifically, replace
values for keys like syncKeys.button/syncKeys.success/syncKeys.error,
temporaryKeys.groups.groupBadge and temporaryKeys.groups.count,
temporaryKeys.toasts.* (createFailed/deleteFailed/downloadFailed),
batchEdit.confirm.syncKeys and batchEdit.confirm.syncKeysDescription,
batchEdit.toast.keysSynced and any placeholders
(placeholders.syncKeysDescription) so they are proper Japanese text (e.g.,
"{count} 件のキー", "キーに同期", a Japanese translation of the overwrite description,
"一時" or similar for groupBadge), ensuring no English remains in these
identifiers.
src/actions/users.ts (1)

1122-1200: ⚠️ Potential issue | 🔴 Critical

严重问题:batchUpdateUsers 验证了新字段但未写入数据库。

第 1127、1132-1135 行通过 UpdateUserSchema.pick 新增了 providerGrouplimitTotalUsdlimitConcurrentSessionsdailyResetModedailyResetTime 五个字段进入校验,但第 1170-1188 行构建 dbUpdates 时只映射了 note / tags / rpm / dailyQuota / limit5hUsd / limit5hResetMode / limitWeeklyUsd / limitMonthlyUsd,导致上述新增字段会被 Zod 校验通过、却在 tx.update(usersTable).set(dbUpdates) 时被静默丢弃。前端批量编辑这些列时,看似成功但实际未持久化,会与本 PR 的 BatchUpdateUsersParams.updates 扩展(行 250、255-258)形成不一致。

建议直接复用本文件已有的 buildUserDbUpdates,避免后续再漏字段。

🐛 建议的修复
-      const dbUpdates: Record<string, unknown> = { updatedAt: new Date() };
-
-      if (updates.note !== undefined) dbUpdates.description = updates.note;
-      if (updates.tags !== undefined) dbUpdates.tags = updates.tags;
-      if (updates.rpm !== undefined) dbUpdates.rpmLimit = updates.rpm;
-      if (updates.dailyQuota !== undefined)
-        dbUpdates.dailyLimitUsd =
-          updates.dailyQuota === null ? null : updates.dailyQuota.toString();
-      if (updates.limit5hUsd !== undefined)
-        dbUpdates.limit5hUsd = updates.limit5hUsd === null ? null : updates.limit5hUsd.toString();
-      if (updates.limit5hResetMode !== undefined)
-        dbUpdates.limit5hResetMode = updates.limit5hResetMode;
-      if (updates.limitWeeklyUsd !== undefined)
-        dbUpdates.limitWeeklyUsd =
-          updates.limitWeeklyUsd === null ? null : updates.limitWeeklyUsd.toString();
-      if (updates.limitMonthlyUsd !== undefined)
-        dbUpdates.limitMonthlyUsd =
-          updates.limitMonthlyUsd === null ? null : updates.limitMonthlyUsd.toString();
+      const dbUpdates: Record<string, unknown> = {
+        ...buildUserDbUpdates(updates, { forceUpdatedAt: true }),
+      };
+      if (updates.limit5hResetMode !== undefined) {
+        dbUpdates.limit5hResetMode = updates.limit5hResetMode;
+      }

注:buildUserDbUpdates 当前未覆盖 limit5hResetMode,请确认该字段是否需要补进 EditableUserData / buildUserDbUpdates,否则保留上面的兜底赋值即可。

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

In `@src/actions/users.ts` around lines 1122 - 1200, The batchUpdateUsers handler
constructs dbUpdates manually and omits the newly validated fields
(providerGroup, limitTotalUsd, limitConcurrentSessions, dailyResetMode,
dailyResetTime), so update persistence is silently lost; replace the manual
dbUpdates construction with a call to the existing buildUserDbUpdates helper (or
merge its result) and then add updatedAt: new Date() before calling
tx.update(usersTable).set(...), making sure to include/merge limit5hResetMode
either by adding it to buildUserDbUpdates or keeping the current inline
assignment as a fallback so all validated fields (providerGroup, limitTotalUsd,
limitConcurrentSessions, dailyResetMode, dailyResetTime, and limit5hResetMode)
are written to the DB.
🟡 Minor comments (16)
src/lib/availability/types.ts-82-85 (1)

82-85: ⚠️ Potential issue | 🟡 Minor

weight 字段的可选标记与数据库约束不一致

weight?: number 的可选标记具有误导性。根据数据库 schema (src/drizzle/schema.ts 第 200 行),weight 被定义为 integer('weight').notNull().default(1),这意味着该字段在数据库中永远不会为 null 或 undefined。

当前代码在 availability-service.ts 第 527 行采用 provider.weight ?? 0 的防御性写法,以及在第 345 行直接使用 weight: providers.weight,都表明 weight 预期总是数字值。因此,TypeScript 接口的 ? 修饰符会误导消费者认为需要编写 weight === undefined 的兜底逻辑。

建议的类型调整
  /** Provider website URL */
  websiteUrl: string | null;
-  /** Provider weight, higher values rank first */
-  weight?: number;
+  /** Provider weight, higher values rank first */
+  weight: number;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/availability/types.ts` around lines 82 - 85, The interface in
types.ts marks the Provider "weight?: number" as optional, which contradicts the
DB schema and runtime usage; change the property to "weight: number" so it is
always a number, then update call sites that defensively handle undefined (e.g.,
replace uses of "provider.weight ?? 0" with "provider.weight" and remove
unnecessary undefined checks) and ensure any object construction (e.g., where
providers.weight is assigned) supplies a numeric weight consistent with the DB
default.
public/examples/api-key-quota-extractor-direct.js-54-58 (1)

54-58: ⚠️ Potential issue | 🟡 Minor

extra 字段会出现 undefined% 脏值。

response.ok 为 false 或某个窗口(fiveHour/daily/weekly/monthly/total)未返回时,对应的 *.remainingPercentundefined,拼接后会得到 "5H剩余:undefined%/日剩余:undefined%/..." 这样的字符串。该 extra 会被第三方客户端(如 cc-switch)直接显示给用户,体验较差。

建议为每个百分比加上默认值,并在无效时跳过拼接:

建议修复
-      extra: "5H剩余:" + fiveHour.remainingPercent + "%"
-        + "/日剩余:" + daily.remainingPercent + "%"
-        + "/周剩余:" + weekly.remainingPercent + "%"
-        + "/月剩余:" + monthly.remainingPercent + "%"
-        + "/总剩余:" + total.remainingPercent + "%"
+      extra: isValid
+        ? "5H剩余:" + (fiveHour.remainingPercent ?? 0) + "%"
+          + "/日剩余:" + (daily.remainingPercent ?? 0) + "%"
+          + "/周剩余:" + (weekly.remainingPercent ?? 0) + "%"
+          + "/月剩余:" + (monthly.remainingPercent ?? 0) + "%"
+          + "/总剩余:" + (total.remainingPercent ?? 0) + "%"
+        : undefined,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@public/examples/api-key-quota-extractor-direct.js` around lines 54 - 58,
extra 字段可能包含 "undefined%" 脏值:在构建 extra 字符串时检查 response.ok 并对
fiveHour/daily/weekly/monthly/total 以及它们的 remainingPercent 做健壮性校验,只有在窗口对象存在且
remainingPercent 为合法数值时才拼接该段;否则为该百分比使用默认值(例如 "N/A" 或 "0")或直接跳过拼接;在生成 extra
的逻辑中更新对 fiveHour、daily、weekly、monthly、total 和 remainingPercent 的访问与拼接逻辑以避免出现
undefined%。
src/app/[locale]/dashboard/_components/dashboard-header.tsx-25-25 (1)

25-25: ⚠️ Potential issue | 🟡 Minor

确认前端国际化 key 已完整添加,但需确认系统状态导航对未登录用户的可见性是否符合预期。

✅ 已验证:所有 5 个语种 (en, ja, ru, zh-CN, zh-TW) 的 dashboard.json 都包含 nav.systemStatus 翻译,不存在缺键风险。

⚠️ 需确认:/status 路由无认证保护(PublicStatusPage,无 auth 检查),而 DashboardHeader 中 systemStatus 导航项未标记 adminOnly: true,导致该链接会在未登录用户的导航栏中显示。

// src/app/[locale]/dashboard/_components/dashboard-header.tsx
const items = NAV_ITEMS.filter((item) => !item.adminOnly || isAdmin);
// systemStatus 无 adminOnly 标记,所以总是包含在 items 中

// MobileNav 和 DashboardNav 在任何情况下都会渲染(包括 session 为 null)
<MobileNav items={items} />
<DashboardNav items={items} />

如果状态页面面向所有用户开放是有意设计,当前行为符合预期;如果需要隐藏该链接可考虑添加 adminOnly: true 或其他访问控制。

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

In `@src/app/`[locale]/dashboard/_components/dashboard-header.tsx at line 25, The
dashboard header currently includes the "systemStatus" NAV_ITEMS entry without
an adminOnly flag so NAV_ITEMS -> items (filtered by const items =
NAV_ITEMS.filter((item) => !item.adminOnly || isAdmin)) will always include it
and both MobileNav and DashboardNav will render the link even for anonymous
users; decide the intended visibility and either add adminOnly: true to the
"systemStatus" entry in NAV_ITEMS to hide it for non-admins or keep it public
and document that PublicStatusPage is intentionally unprotected (also consider
checking session/isAdmin logic used by items, MobileNav and DashboardNav to
ensure consistent access control).
messages/ru/dashboard.json-1644-1651 (1)

1644-1651: ⚠️ Potential issue | 🟡 Minor

temporaryKeys.groups.counttoasts.deleteSuccess 中的 "keys" 未本地化

"{count} keys""Группа {group} удалена ({count} keys)" 中嵌入了英文单词 "keys"。建议替换为俄语 ключей(或 ключи,根据复数规则使用 ICU plural),保持与同文件其它术语一致。

建议改动
-        "count": "{count} keys",
+        "count": "{count, plural, one {# ключ} few {# ключа} many {# ключей} other {# ключа}}",
...
-        "deleteSuccess": "Группа {group} удалена ({count} keys)",
+        "deleteSuccess": "Группа {group} удалена ({count, plural, one {# ключ} few {# ключа} many {# ключей} other {# ключа}})",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@messages/ru/dashboard.json` around lines 1644 - 1651,
temporaryKeys.groups.count and toasts.deleteSuccess contain the English word
"keys" and should be localized to Russian; update the values for the keys
temporaryKeys.groups.count and toasts.deleteSuccess to use the Russian term
(e.g., "ключей" or an ICU plural pattern like "{count, plural, one {# ключ} few
{# ключа} many {# ключей} other {# ключей}}") so they match the rest of the file
and handle pluralization correctly.
src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx-308-349 (1)

308-349: ⚠️ Potential issue | 🟡 Minor

单用户同步未先持久化用户设置,可能导致 user 记录与 keys 不一致

此处 handleSyncKeys 直接用表单当前草稿 form.values || defaultValues 调用 syncUserConfigToKeys,并未先调用 editUser 持久化。但批量编辑流程的 i18n 描述明确写着 “user fields from this batch edit are saved first, then all undeleted keys for each user are overwritten from that user's settings.”。

由此带来两个潜在问题:

  • 用户在表单里改了字段后点 “Synchronize to Keys”,再关闭对话框(不点 Save),结果是子 keys 被以未保存的草稿值覆盖,而 user 记录仍是旧值,二者出现不一致。
  • 表单校验(例如非负数、上限)也未在同步前执行,错误只能依赖服务端兜底,UX 偏弱。

建议要么先 editUser 成功后再同步(与批量流程一致并自动加入 zod 校验),要么在 UI 上明确提示“同步使用未保存的草稿值”。

参考改法(先保存再同步)
   const handleSyncKeys = async () => {
     setIsSyncingKeys(true);
     try {
       const data = form.values || defaultValues;
+      // 先持久化用户字段,避免 user 与 keys 不一致
+      const userRes = await editUser(user.id, {
+        name: data.name,
+        note: data.note,
+        tags: data.tags,
+        expiresAt: data.expiresAt ?? null,
+        providerGroup: normalizeProviderGroup(data.providerGroup),
+        rpm: data.rpm,
+        limit5hUsd: data.limit5hUsd,
+        limit5hResetMode: data.limit5hResetMode,
+        dailyQuota: data.dailyQuota,
+        limitWeeklyUsd: data.limitWeeklyUsd,
+        limitMonthlyUsd: data.limitMonthlyUsd,
+        limitTotalUsd: data.limitTotalUsd,
+        limitConcurrentSessions: data.limitConcurrentSessions,
+        dailyResetMode: data.dailyResetMode,
+        dailyResetTime: data.dailyResetTime,
+        allowedClients: data.allowedClients,
+        blockedClients: data.blockedClients,
+        allowedModels: data.allowedModels,
+      });
+      if (!userRes.ok) {
+        toast.error(userRes.error || t("editDialog.saveFailed"));
+        return;
+      }
       const res = await syncUserConfigToKeys(user.id, {
         ...
       });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`[locale]/dashboard/_components/user/edit-user-dialog.tsx around
lines 308 - 349, handleSyncKeys currently sends unsaved draft values
(form.values || defaultValues) straight to syncUserConfigToKeys causing user vs
keys inconsistency and skipping client-side validation; change it to first
validate and persist the user via editUser (use form.validate() or call editUser
with the same payload derived from form.values/defaultValues), await successful
editUser response, and only then call syncUserConfigToKeys with the saved values
(or the returned saved user data); ensure you abort syncing on editUser failure
and surface validation/server errors to the user, and keep setIsSyncingKeys
state and toasts/error handling as before.
src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx-332-352 (1)

332-352: ⚠️ Potential issue | 🟡 Minor

确认用户界面需要更清楚地说明当同时勾选用户字段和"同步到 keys"时的行为。

syncUsersToKeys 为 true 时,代码的逻辑是正确的:batchUpdateUsers 不会被独立调用(采用 if-else 结构),pendingUpdate.userUpdates 会被原样转交给 batchSyncUserConfigToKeysbatchSyncUserConfigToKeys 确实会处理这些字段(note、tags、rpm、dailyQuota、providerGroup、limit5hUsd 等)。

但当用户同时启用用户字段和"同步到 keys"时,确认对话框会并列展示这两个部分:

  • "用户字段":列出已启用的用户字段
  • "同步到 Key":显示"会按当前用户配置覆盖这 {users} 个用户的全部未删除 Key"

问题在于"同步到 Key"的描述文案没有显式说明用户字段的更新会通过此同步操作一并应用。用户可能看到两个部分并感到困惑,因为看起来会有两个独立的操作,但实际上只会发出一个请求。建议在 confirm.syncKeysDescription 中补充说明,或者调整 UI 逻辑,使当启用 sync 时仅展示 sync 部分(而不同时展示用户字段部分)。

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

In `@src/app/`[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx
around lines 332 - 352, 当前逻辑在 pendingUpdate.syncUsersToKeys 为 true 时只调用
batchSyncUserConfigToKeys(而不是
batchUpdateUsers),但确认对话框(confirm.syncKeysDescription)同时展示“用户字段”和“同步到
Key”,导致用户误解为两个独立操作;请修改 UI 文案或展示逻辑:要么在 confirm.syncKeysDescription 中明确补充说明“当启用
syncUsersToKeys 时,上述用户字段更新将通过 batchSyncUserConfigToKeys 应用到这些用户的所有未删除
Key”,要么在渲染确认内容时检测 pendingUpdate.syncUsersToKeys 并只显示“同步到
Key”部分(隐藏单独的“用户字段”列表);参考符号:pendingUpdate.syncUsersToKeys、pendingUpdate.userUpdates、batchSyncUserConfigToKeys、batchUpdateUsers、confirm.syncKeysDescription
来定位并实现修复。
src/app/[locale]/settings/config/_components/system-settings-form.tsx-247-247 (1)

247-247: ⚠️ Potential issue | 🟡 Minor

Number(costMultiplierCorrection || 0) 在异常输入下会把 NaN/Infinity 发给后端。

虽然 input type="number" 能挡住大多数手输错值,但浏览器对 step/min/max 并非强制(粘贴、撤销、e 指数等仍可能遗留非法字符串),此时:

  • "abc"Number("abc") = NaN
  • "1e500"Number(...) = Infinity

会被透传到 saveSystemSettings,依赖后端校验返回错误。建议在提交前显式做一次有限性校验,与你已经在做的 quota refresh interval clamp 风格保持一致。

建议在 handleSubmit 入口提前 clamp / 校验
     const quotaDbRefreshIntervalSecondsToSave = clampQuotaDbRefreshIntervalSeconds(
       quotaDbRefreshIntervalSecondsStr
     );
+
+    const costMultiplierCorrectionParsed = Number(costMultiplierCorrection);
+    const costMultiplierCorrectionToSave = Number.isFinite(costMultiplierCorrectionParsed)
+      ? Math.max(-100, Math.min(100, costMultiplierCorrectionParsed))
+      : 0;
@@
-        codexPriorityBillingSource,
-        costMultiplierCorrection: Number(costMultiplierCorrection || 0),
+        codexPriorityBillingSource,
+        costMultiplierCorrection: costMultiplierCorrectionToSave,

Also applies to: 121-123

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

In `@src/app/`[locale]/settings/config/_components/system-settings-form.tsx at
line 247, costMultiplierCorrection may become NaN/Infinity when parsing user
input (e.g., "abc" or "1e500") and is currently passed to saveSystemSettings as
Number(costMultiplierCorrection || 0); update the handleSubmit flow to validate
and normalize numeric fields (including costMultiplierCorrection and the other
fields at lines 121-123) before building the payload: parse the string
(parseFloat/Number), check Number.isFinite(value), and if not finite replace
with a safe default or clamped value (same clamp style you use for quota refresh
interval), then pass that finite number into saveSystemSettings to avoid
transmitting NaN/Infinity to the backend.
docs/examples/api-key-quota-extractor-direct.js-54-58 (1)

54-58: ⚠️ Potential issue | 🟡 Minor

extra 字符串在字段缺失时会输出字面量 "undefined%"。

response 异常或 quotaWindows 中任一窗口缺失时,fiveHour/daily/weekly/monthly/total 都会回退为 {},其 remainingPercentundefined,最终 extra 会拼出形如 "5H剩余:undefined%/日剩余:undefined%..." 的串,直接在第三方工具里给用户看,体验不佳;和同 PR 里 api-key-quota-extractor-weekly.jstoNumber 兜底的做法也不一致。

建议为 percent 取值加一层兜底
+    const formatPercent = function(value) {
+      return typeof value === "number" && Number.isFinite(value) ? value + "%" : "-";
+    };
+
     return {
       isValid: !!isValid,
       invalidMessage: isValid ? undefined : "套餐不可用",
       planName: "Total Quota",
       ...
-      extra: "5H剩余:" + fiveHour.remainingPercent + "%"
-        + "/日剩余:" + daily.remainingPercent + "%"
-        + "/周剩余:" + weekly.remainingPercent + "%"
-        + "/月剩余:" + monthly.remainingPercent + "%"
-        + "/总剩余:" + total.remainingPercent + "%"
+      extra: "5H剩余:" + formatPercent(fiveHour.remainingPercent)
+        + "/日剩余:" + formatPercent(daily.remainingPercent)
+        + "/周剩余:" + formatPercent(weekly.remainingPercent)
+        + "/月剩余:" + formatPercent(monthly.remainingPercent)
+        + "/总剩余:" + formatPercent(total.remainingPercent)
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/examples/api-key-quota-extractor-direct.js` around lines 54 - 58, The
constructed extra string can contain "undefined%" when any quota window
(fiveHour, daily, weekly, monthly, total) is missing because remainingPercent
falls back to undefined; update the code that builds extra to coerce each
remainingPercent to a safe fallback (e.g., use the existing toNumber utility or
Number(...) with a nullish/default of 0) before concatenation so you never emit
"undefined%". Reference the variables fiveHour, daily, weekly, monthly, total
and the remainingPercent property and make the implementation consistent with
api-key-quota-extractor-weekly.js's toNumber fallback approach.
tests/unit/actions/my-usage-concurrent-inherit.test.ts-160-197 (1)

160-197: ⚠️ Potential issue | 🟡 Minor

确认语义混用是代码实现的设计选择,建议补充设计文档说明。

测试正确反映了实现行为:

  • keyLimitTotalUsd 为 null 时,resolveTotalLimitWithMonthlyFallback() 会降级到月度限额(30)
  • usedTotalUsd 仍使用累计成本(12),而不是月度成本(5)
  • 导致 effectiveTotal 对标的是"月度上限 vs 全周期累计",而非同一时间窗口

resolveEffectiveQuotaWindow() 的设计是返回key和user中约束最严格的那一个(remaining最小),所以混用月度和累计是为了确保系统总能强制某个上限。这看起来是有意的实现,符合PR目标"Fix fallback total quota to monthly quota"。

但这种跨时间窗口的混用容易引起误解:用户看到"剩余18"时,可能误认为是本月剩余额度(实际本月剩25)。建议在代码注释或API文档中明确说明这种回退场景下 limitTotalUsdusedTotalUsd 语义的定义。

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

In `@tests/unit/actions/my-usage-concurrent-inherit.test.ts` around lines 160 -
197, Summary: tests reveal that when resolveTotalLimitWithMonthlyFallback()
falls back to monthly limits, usedTotalUsd still comes from cumulative totals,
producing mixed time-window semantics (monthly limit vs lifetime usage). Fix:
update the implementation and docs by adding a clear comment in
resolveTotalLimitWithMonthlyFallback() and resolveEffectiveQuotaWindow() (and
mention in getMyQuota() where result.data is assembled) that when total limits
are null the code intentionally uses monthly limits while usedTotalUsd may
remain cumulative; either (a) align semantics by switching usedTotalUsd to the
matching monthly cost when a monthly fallback is used, or (b) keep current
behavior but explicitly document the cross-window semantics and surface a
boolean flag (e.g., totalLimitIsMonthlyFallback) in the returned payload so
callers/clients can interpret remainingTotalUsd correctly; pick one approach and
implement the corresponding change and unit test update.
docs/examples/api-key-quota-extractor.js-56-60 (1)

56-60: ⚠️ Potential issue | 🟡 Minor

extra 字段在窗口未配置或不限额时会拼出字面量 null%

getMyQuota 的契约里 *.remainingPercent 在“不限额或限额为 0”时返回 null(见 route.ts Line 1285)。toNumber(weekly.remainingPercent, null) 在 fallback 也是 null 时整体返回 null,与 "%" 字符串拼接后会得到 "/周剩余:null%"。同理 5H剩余/月剩余/总剩余 也都可能出现 null%。建议对每段输出做空值兜底(例如不限额时显示 -),否则展示给用户的字符串会很难看。

建议的修复思路
+    const formatPercent = function(value) {
+      return typeof value === "number" && Number.isFinite(value) ? value + "%" : "-";
+    };
@@
-      extra: "5H剩余:" + toNumber(fiveHour.remainingPercent, data.todayRemainingPercent) + "%"
-        + "/日剩余:" + toNumber(daily.remainingPercent, data.todayRemainingPercent) + "%"
-        + "/周剩余:" + toNumber(weekly.remainingPercent, null) + "%"
-        + "/月剩余:" + toNumber(monthly.remainingPercent, null) + "%"
-        + "/总剩余:" + toNumber(total.remainingPercent, data.remainingPercent) + "%"
+      extra: "5H剩余:" + formatPercent(toNumber(fiveHour.remainingPercent, data.todayRemainingPercent))
+        + "/日剩余:" + formatPercent(toNumber(daily.remainingPercent, data.todayRemainingPercent))
+        + "/周剩余:" + formatPercent(weekly.remainingPercent)
+        + "/月剩余:" + formatPercent(monthly.remainingPercent)
+        + "/总剩余:" + formatPercent(toNumber(total.remainingPercent, data.remainingPercent))

另外,5H 和日剩余的 fallback 都用 data.todayRemainingPercent 略显奇怪,5H 没有专属 fallback 时直接展示 - 可能更准确。

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

In `@docs/examples/api-key-quota-extractor.js` around lines 56 - 60, The `extra`
string concatenation can produce "null%" when any of the remainingPercent fields
are null; update the calls that build `extra` (the uses of toNumber with
fiveHour.remainingPercent, daily.remainingPercent, weekly.remainingPercent,
monthly.remainingPercent, total.remainingPercent and the fallbacks
data.todayRemainingPercent / data.remainingPercent) to supply a non-null display
fallback (e.g., '∞' or '-' ) instead of null, and change the 5H fallback to '-'
rather than data.todayRemainingPercent; ensure toNumber or its caller returns a
string like '-' or '∞' when the value is null so concatenation never yields
"null%".
messages/ja/dashboard.json-1690-1696 (1)

1690-1696: ⚠️ Potential issue | 🟡 Minor

下载按钮文案写成了 CSV,但前端实际导出的是 .txt 纯文本。

TemporaryKeyBatchDialog 下载的是换行分隔的文本文件,不是 CSV。这里继续写“CSV をダウンロード”会让用户预期和实际内容不一致。

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

In `@messages/ja/dashboard.json` around lines 1690 - 1696, The label "CSV をダウンロード"
is misleading because TemporaryKeyBatchDialog exports a newline-separated .txt
file; update the "download" message in messages/ja/dashboard.json (key
"download") to reflect the actual format, e.g. "テキストファイルをダウンロード (.txt)" and
ensure TemporaryKeyBatchDialog uses this message so the UI and exported file
type match.
src/app/[locale]/dashboard/_components/user/temporary-key-batch-dialog.tsx-119-130 (1)

119-130: ⚠️ Potential issue | 🟡 Minor

count 这里会接受小数,最终批量数会被服务端悄悄截断。

现在 1.52.9 这类值会通过前端校验,但服务端会在 createTemporaryKeysBatchMath.trunc()1/2。用户看到的是自己输入了一个值,实际创建出来却是另一个数量,属于静默错单。这里至少要改成 Number.isInteger(parsedCount),并顺手和服务端保持同样的上限校验。

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

In `@src/app/`[locale]/dashboard/_components/user/temporary-key-batch-dialog.tsx
around lines 119 - 130, The current validation converts count to parsedCount but
allows non-integers (e.g., 1.5) which the server silently truncates; change the
check to require an integer by using Number.isInteger(parsedCount) instead of
Number.isFinite(parsedCount), and add an upper-bound check matching the server's
limit (the same constant/limit used by createTemporaryKeysBatch) so values >
MAX_BATCH_SIZE are rejected with toast.error(t("createDialog.invalidCount")).
Update the validation around parsedCount (where count is parsed and the toast is
shown) to enforce both parsedCount > 0, Number.isInteger(parsedCount), and
parsedCount <= SERVER_MAX_BATCH_SIZE.
src/actions/users.ts-1460-1463 (1)

1460-1463: ⚠️ Potential issue | 🟡 Minor

缓存失效错误被静默吞掉。

invalidateCachedUser / invalidateCachedKey 这里以 () => {} 吞错,事务已提交但若缓存清理失败会导致 API Key 鉴权层读到陈旧的用户/Key 配置(限额、provider group 等),且无任何日志可追踪。建议至少 logger.warn 记录失败上下文(与 batchUpdateUsers 中处理 clearUserCostCache 的方式一致)。syncUserConfigToKeys 第 2247-2248 行同样问题。

🛡️ 建议的修复
-    await Promise.all([
-      ...requestedIds.map((userId) => invalidateCachedUser(userId).catch(() => {})),
-      ...keyStringsForCache.map((key) => invalidateCachedKey(key).catch(() => {})),
-    ]);
+    await Promise.all([
+      ...requestedIds.map((userId) =>
+        invalidateCachedUser(userId).catch((error) => {
+          logger.warn("[UserAction] Failed to invalidate user cache after batch sync", {
+            userId,
+            error: error instanceof Error ? error.message : String(error),
+          });
+        }),
+      ),
+      ...keyStringsForCache.map((key) =>
+        invalidateCachedKey(key).catch((error) => {
+          logger.warn("[UserAction] Failed to invalidate key cache after batch sync", {
+            error: error instanceof Error ? error.message : String(error),
+          });
+        }),
+      ),
+    ]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/actions/users.ts` around lines 1460 - 1463, The current Promise.all calls
silently swallow errors from invalidateCachedUser and invalidateCachedKey;
update both places (the array using invalidateCachedUser/invalidateCachedKey and
the similar code in syncUserConfigToKeys) to catch errors and log a warning
instead of an empty handler—use the existing logger.warn pattern used by
batchUpdateUsers/clearUserCostCache, include contextual identifiers (userId or
key string) and the caught error object so failed cache invalidations are
recorded for debugging and monitoring.
src/actions/users.ts-1644-1670 (1)

1644-1670: ⚠️ Potential issue | 🟡 Minor

使用已创建的 newUser 字段而非 validatedData 来构建默认密钥配置。

createUser 函数对多个字段应用了默认值(如 dailyResetMode 默认为 "fixed"dailyResetTime 默认为 "00:00"),但此处直接传递 validatedDatabuildFirstSyncedKeyConfig。如果 validatedData 中的这些字段为 undefined,用户记录会被保存为默认值,而默认密钥配置则会接收到 undefined,导致用户记录与密钥配置不一致。应改用 newUser 的实际字段来构建:

const defaultKeyConfig = buildFirstSyncedKeyConfig({
  dailyQuota: newUser.dailyQuota ?? null,
  limit5hUsd: newUser.limit5hUsd ?? null,
  limitWeeklyUsd: newUser.limitWeeklyUsd ?? null,
  limitMonthlyUsd: newUser.limitMonthlyUsd ?? null,
  limitTotalUsd: newUser.limitTotalUsd ?? null,
  limitConcurrentSessions: newUser.limitConcurrentSessions ?? null,
- providerGroup,
+ providerGroup: newUser.providerGroup ?? providerGroup,
- dailyResetMode: validatedData.dailyResetMode,
- dailyResetTime: validatedData.dailyResetTime,
+ dailyResetMode: newUser.dailyResetMode,
+ dailyResetTime: newUser.dailyResetTime,
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/actions/users.ts` around lines 1644 - 1670, The default key config is
being built from validatedData which may be missing defaults applied when the
user was saved; update the call to buildFirstSyncedKeyConfig to use the
persisted newUser fields (e.g. newUser.dailyQuota, newUser.limit5hUsd,
newUser.limitWeeklyUsd, newUser.limitMonthlyUsd, newUser.limitTotalUsd,
newUser.limitConcurrentSessions, newUser.dailyResetMode, newUser.dailyResetTime
and newUser.providerGroup) so the defaultKeyConfig and the created key in
createKey reflect the actual stored user values (keep using
buildFirstSyncedKeyConfig and createKey but pass newUser's properties instead of
validatedData).
scripts/deploy.sh-514-514 (1)

514-514: ⚠️ Potential issue | 🟡 Minor

${APP_PORT:-${APP_PORT}} 中的默认值与变量本身重复,等同于 ${APP_PORT}

这种写法在 default 分支没有起到任何兜底作用:当 compose 运行时 APP_PORT 未设置时,整个表达式仍展开为空字符串。建议明确写出真正的兜底端口(与脚本其他位置保持一致使用 23000):

🛠️ 建议修正
-      PORT: \${APP_PORT:-${APP_PORT}}
+      PORT: \${APP_PORT:-23000}
-      - "\${APP_PORT:-${APP_PORT}}:\${APP_PORT:-${APP_PORT}}"
+      - "\${APP_PORT:-23000}:\${APP_PORT:-23000}"
-      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:' + (process.env.PORT || \${APP_PORT:-${APP_PORT}}) + '/api/actions/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
+      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:' + (process.env.PORT || \${APP_PORT:-23000}) + '/api/actions/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]

注意:保留 \$ 让 docker compose 在运行时解释 ${APP_PORT:-23000},而不是在 heredoc 写入时由 bash 展开。

Also applies to: 527-527, 536-536

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

In `@scripts/deploy.sh` at line 514, Replace the redundant default-expansion
pattern PORT: \${APP_PORT:-${APP_PORT}} with an explicit fallback PORT:
\${APP_PORT:-23000} so docker-compose receives a real default port; ensure you
keep the escaped dollar sign (\$) in the heredoc so the expression is evaluated
at compose runtime. Update every occurrence of the pattern (the lines that
currently contain PORT: \${APP_PORT:-${APP_PORT}}) to use PORT:
\${APP_PORT:-23000}.
scripts/server-zero-downtime-rollout.sh-229-229 (1)

229-229: ⚠️ Potential issue | 🟡 Minor

/tmp 锁路径可预测,存在被本地用户提前占位造成 DoS 的可能。

LOCK_DIR=/tmp/<project>.<domain>.rollout.lock 在多用户主机上可被任意本地用户预先 mkdir 占用,从而阻止本进程获取锁。如果该机器是单租户专用的部署机,影响很小;否则建议改用 /var/lockXDG_RUNTIME_DIR 这类受目录权限保护的路径,或者在 mkdir 失败时进一步检查持有者。

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

In `@scripts/server-zero-downtime-rollout.sh` at line 229, 当前使用的 LOCK_DIR 变量在
scripts/server-zero-downtime-rollout.sh 指向 /tmp/... 可被任意本地用户预先 mkdir 导致 DoS;更新
LOCK_DIR 的计算以优先使用受保护的目录(例如 XDG_RUNTIME_DIR 或 /var/lock)并仅在这些不可用时回退到 /tmp,且在
mkdir 失败时通过检查现有目录的所有者(stat/lsuid)来验证是否为竞争锁而不是错误,然后根据持有者决定失败还是等待/报错;具体修复点为修改
LOCK_DIR 定义和在锁创建/失败分支中对 LOCK_DIR 的所有者检查及相应处理逻辑(引用符号:LOCK_DIR 变量与脚本
scripts/server-zero-downtime-rollout.sh 的锁创建/释放代码)。

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 862260a3-58ba-4afd-8ec0-fc0a9a59ffd0

📥 Commits

Reviewing files that changed from the base of the PR and between 3d0d06d and ae81378.

📒 Files selected for processing (92)
  • .env.example
  • .gitignore
  • Dockerfile
  • README.en.md
  • README.md
  • dev/docker-compose.yaml
  • docker-compose.yaml
  • docs/examples/api-key-quota-extractor-compatible.js
  • docs/examples/api-key-quota-extractor-daily.js
  • docs/examples/api-key-quota-extractor-direct.js
  • docs/examples/api-key-quota-extractor-total.js
  • docs/examples/api-key-quota-extractor-weekly.js
  • docs/examples/api-key-quota-extractor.js
  • docs/examples/temporary-key-groups.md
  • drizzle/0099_powerful_micromax.sql
  • drizzle/meta/0099_snapshot.json
  • drizzle/meta/_journal.json
  • messages/en/dashboard.json
  • messages/en/settings/config.json
  • messages/ja/dashboard.json
  • messages/ja/settings/config.json
  • messages/ru/dashboard.json
  • messages/ru/settings/config.json
  • messages/zh-CN/dashboard.json
  • messages/zh-CN/settings/config.json
  • messages/zh-TW/dashboard.json
  • messages/zh-TW/settings/config.json
  • plan/2026-04-25_18-53-35-cch.md
  • public/examples/api-key-quota-extractor-compatible.js
  • public/examples/api-key-quota-extractor-direct.js
  • public/examples/api-key-quota-extractor.js
  • scripts/deploy.ps1
  • scripts/deploy.sh
  • scripts/server-zero-downtime-rollout.sh
  • src/actions/keys.ts
  • src/actions/my-usage.ts
  • src/actions/system-config.ts
  • src/actions/users.ts
  • src/app/[locale]/dashboard/_components/dashboard-header.tsx
  • src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx
  • src/app/[locale]/dashboard/_components/user/batch-edit/batch-user-section.tsx
  • src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx
  • src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx
  • src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx
  • src/app/[locale]/dashboard/_components/user/temporary-key-batch-dialog.tsx
  • src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx
  • src/app/[locale]/dashboard/_components/user/user-management-table.tsx
  • src/app/[locale]/dashboard/users/users-page-client.tsx
  • src/app/[locale]/settings/config/_components/system-settings-form.tsx
  • src/app/[locale]/settings/config/page.tsx
  • src/app/api/actions/[...route]/route.ts
  • src/app/api/admin/system-config/route.ts
  • src/app/api/status/route.ts
  • src/app/v1/_lib/proxy/auth-guard.ts
  • src/app/v1/_lib/proxy/provider-selector.ts
  • src/app/v1/_lib/proxy/session.ts
  • src/drizzle/schema.ts
  • src/lib/auth.ts
  • src/lib/auth/public-path-policy.ts
  • src/lib/auth/readonly-access.ts
  • src/lib/availability/availability-service.ts
  • src/lib/availability/types.ts
  • src/lib/billing/cost-multiplier.ts
  • src/lib/config/system-settings-cache.ts
  • src/lib/keys/temporary-key-groups.ts
  • src/lib/my-usage/readonly-redaction.ts
  • src/lib/users/user-key-sync.ts
  • src/lib/validation/schemas.ts
  • src/proxy.ts
  • src/repository/_shared/transformers.ts
  • src/repository/key.ts
  • src/repository/system-config.ts
  • src/types/key.ts
  • src/types/system-config.ts
  • src/types/user.ts
  • tests/api/api-actions-integrity.test.ts
  • tests/api/my-usage-readonly.test.ts
  • tests/configs/my-usage.config.ts
  • tests/unit/actions/my-usage-concurrent-inherit.test.ts
  • tests/unit/actions/temporary-keys.test.ts
  • tests/unit/actions/users-key-sync.test.ts
  • tests/unit/actions/users-reset-all-statistics.test.ts
  • tests/unit/api/status-route-alias.test.ts
  • tests/unit/auth/auth-scoped-session-branches.test.ts
  • tests/unit/lib/billing/cost-multiplier.test.ts
  • tests/unit/lib/config/system-settings-cache.test.ts
  • tests/unit/lib/keys/temporary-key-groups.test.ts
  • tests/unit/lib/users/user-key-sync.test.ts
  • tests/unit/proxy/auth-guard.test.ts
  • tests/unit/proxy/proxy-auth-cookie-passthrough.test.ts
  • tests/unit/user-dialogs.test.tsx
  • tests/vitest.base.ts

"usageLogs": "Usage Logs",
"leaderboard": "Leaderboard",
"availability": "Availability",
"systemStatus": "System Status",
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 验证临时密钥/同步相关键在所有语言文件中是否齐全
for f in $(fd -t f 'dashboard.json$' messages); do
  echo "=== $f ==="
  jq 'paths(scalars) as $p | select(($p | join(".")) | test("temporaryKeys|syncKeys|addTemporaryKey|nav\\.systemStatus")) | $p | join(".")' "$f" | sort -u
done

Repository: ding113/claude-code-hub

Length of output: 13251


修复 i18n 多语言同步不完整问题

验证发现 temporaryKeyssyncKeys 相关的 i18n 键在 5 个语言文件中同步不完整。具体来说:

  • messages/en/dashboard.jsonmessages/zh-CN/dashboard.json 包含全部 52 个键
  • messages/ja/dashboard.jsonmessages/ru/dashboard.jsonmessages/zh-TW/dashboard.json 各缺少以下 8 个键:
    • userManagement.temporaryKeys.listActions.showLess
    • userManagement.temporaryKeys.listActions.showMore
    • userManagement.temporaryKeys.sections.standard.description
    • userManagement.temporaryKeys.sections.standard.empty
    • userManagement.temporaryKeys.sections.standard.title
    • userManagement.temporaryKeys.sections.temporary.description
    • userManagement.temporaryKeys.sections.temporary.empty
    • userManagement.temporaryKeys.sections.temporary.title

需要立即补齐日文、俄文、繁体中文文件中的缺失键,避免运行时出现 MISSING_MESSAGE 警告或不正确的回退行为。

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

In `@messages/en/dashboard.json` at line 781, Missing i18n keys for temporaryKeys
and syncKeys cause incomplete localization; add the eight missing keys
(userManagement.temporaryKeys.listActions.showLess,
userManagement.temporaryKeys.listActions.showMore,
userManagement.temporaryKeys.sections.standard.description,
userManagement.temporaryKeys.sections.standard.empty,
userManagement.temporaryKeys.sections.standard.title,
userManagement.temporaryKeys.sections.temporary.description,
userManagement.temporaryKeys.sections.temporary.empty,
userManagement.temporaryKeys.sections.temporary.title) to the Japanese, Russian,
and Traditional Chinese dashboard locale files, populating their values by
copying the corresponding strings from the English (or zh-CN) entries to ensure
each locale has all 52 keys and prevents MISSING_MESSAGE warnings.

Comment on lines +16 to +19
"billingCorrection": {
"title": "Billing and Rate Correction",
"description": "Configure billing model source, Codex Priority billing rate source, and the Claude billing header rectifier in one place."
},
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

俄语本地化文件中残留英文文案。

billingCorrection.titlebillingCorrection.descriptionmessages/ru/settings/config.json 中仍是英文,而该文件其余条目均为俄语,会导致俄语界面显示英文标题和描述,违反 i18n 规范。请补齐俄语翻译。

建议补齐俄语翻译(请由具备俄语能力的人员校对最终用词)
     "billingCorrection": {
-      "title": "Billing and Rate Correction",
-      "description": "Configure billing model source, Codex Priority billing rate source, and the Claude billing header rectifier in one place."
+      "title": "Коррекция тарификации и ставок",
+      "description": "Настройте источник модели тарификации, источник тарификации Codex Priority и корректор заголовка тарификации Claude в одном месте."
     },

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".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"billingCorrection": {
"title": "Billing and Rate Correction",
"description": "Configure billing model source, Codex Priority billing rate source, and the Claude billing header rectifier in one place."
},
"billingCorrection": {
"title": "Коррекция тарификации и ставок",
"description": "Настройте источник модели тарификации, источник тарификации Codex Priority и корректор заголовка тарификации Claude в одном месте."
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@messages/ru/settings/config.json` around lines 16 - 19, Replace the leftover
English strings for billingCorrection.title and billingCorrection.description in
the Russian localization with proper Russian text: set billingCorrection.title
to "Коррекция выставления счетов и тарифов" and billingCorrection.description to
"Настройте источник модели биллинга, источник тарифов Codex Priority и
корректировщик заголовков биллинга Claude в одном месте."; ensure you update the
keys billingCorrection.title and billingCorrection.description (in
messages/ru/settings/config.json) with these Russian strings, keep valid JSON
quoting/commas, and have a Russian speaker proofread the final wording.

Comment on lines +353 to +358
find_container_by_host_port() {
local port="$1"
docker ps --format '{{.Names}}\t{{.Ports}}' | awk -v port="$port" '
$0 ~ ("127.0.0.1:" port "->") {print $1; exit}
'
}
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 验证当前仓库内 compose 文件的端口绑定形式,确认是否广泛使用 0.0.0.0 绑定(即 host 一侧不带 IP)
fd -t f -e yaml -e yml | xargs rg -nP --no-heading 'ports\s*:|"\s*\$?\{?APP_PORT' -C2

Repository: ding113/claude-code-hub

Length of output: 4404


🏁 Script executed:

# Check the find_container_by_host_port function and its callers
cat -n scripts/server-zero-downtime-rollout.sh | sed -n '353,358p'

Repository: ding113/claude-code-hub

Length of output: 286


🏁 Script executed:

# Check LIVE_CONTAINER usage around line 556
cat -n scripts/server-zero-downtime-rollout.sh | sed -n '550,570p'

Repository: ding113/claude-code-hub

Length of output: 876


🏁 Script executed:

# Check stop_old_live_container function
rg -n 'stop_old_live_container' scripts/server-zero-downtime-rollout.sh -A 8

Repository: ding113/claude-code-hub

Length of output: 2482


🏁 Script executed:

# Verify docker ps output format for 0.0.0.0 bindings
# Let's check docker documentation or common patterns
cat scripts/server-zero-downtime-rollout.sh | grep -n 'docker ps' | head -20

Repository: ding113/claude-code-hub

Length of output: 251


🏁 Script executed:

# Check KEEP_OLD_RUNNING default and argument parsing
rg -n 'KEEP_OLD_RUNNING' scripts/server-zero-downtime-rollout.sh | head -20

Repository: ding113/claude-code-hub

Length of output: 346


🏁 Script executed:

# Check how arguments are parsed, especially stop-old-after-cutover
rg -n 'getopt|--stop|--keep' scripts/server-zero-downtime-rollout.sh | head -30

Repository: ding113/claude-code-hub

Length of output: 302


🏁 Script executed:

# Check what docker ps actually outputs for 0.0.0.0 binding
# Let's see if there's any documentation or comments about this
rg -n '0\.0\.0\.0|PORT.*FORMAT|Ports' scripts/server-zero-downtime-rollout.sh | head -20

Repository: ding113/claude-code-hub

Length of output: 359


🏁 Script executed:

# Check the reclaim logic and conditions around line 602
cat -n scripts/server-zero-downtime-rollout.sh | sed -n '590,610p'

Repository: ding113/claude-code-hub

Length of output: 1340


🏁 Script executed:

# Check stop_non_live_standard_holder function
rg -n 'stop_non_live_standard_holder' scripts/server-zero-downtime-rollout.sh -A 8

Repository: ding113/claude-code-hub

Length of output: 813


🏁 Script executed:

# Verify what docker ps actually outputs by examining the format directive
# and checking docker documentation patterns
rg -n 'Ports.*->' scripts/server-zero-downtime-rollout.sh -B 2 -A 2

Repository: ding113/claude-code-hub

Length of output: 49


🏁 Script executed:

# Let's check if there are any other port lookup mechanisms or fallbacks
rg -n 'port_is_free|docker inspect.*Port' scripts/server-zero-downtime-rollout.sh | head -20

Repository: ding113/claude-code-hub

Length of output: 339


🏁 Script executed:

# Check start_compose_on_standard_port to see how it binds ports
rg -n 'start_compose_on_standard_port' scripts/server-zero-downtime-rollout.sh -A 15 | head -40

Repository: ding113/claude-code-hub

Length of output: 1877


🏁 Script executed:

# Verify if find_container_by_host_port gets called with just the port number
# and understand what scenarios where 0.0.0.0 binding happens
sed -n '460,500p' scripts/server-zero-downtime-rollout.sh

Repository: ding113/claude-code-hub

Length of output: 1450


🏁 Script executed:

# Check if there's any actual scenario where LIVE_CONTAINER would be used
# if LIVE_PORT doesn't match a compose container
sed -n '554,570p' scripts/server-zero-downtime-rollout.sh

Repository: ding113/claude-code-hub

Length of output: 686


🏁 Script executed:

# Check what get_domain_proxy_port returns and when it's called
rg -n 'get_domain_proxy_port' scripts/server-zero-downtime-rollout.sh -B 2 -A 10 | head -40

Repository: ding113/claude-code-hub

Length of output: 1375


🏁 Script executed:

# Let's verify the exact docker ps output format for 0.0.0.0 bindings
# by checking Docker documentation or comments
rg -n 'docker ps|127.0.0.1|0.0.0.0' scripts/server-zero-downtime-rollout.sh | grep -i 'comment\|#\|format' | head -20

Repository: ding113/claude-code-hub

Length of output: 251


🏁 Script executed:

# Let's verify the exact docker ps Ports output format for 0.0.0.0 binding
# by checking if there are any test files or examples
fd -t f 'docker' -x grep -l '0\.0\.0\.0' {} \;

Repository: ding113/claude-code-hub

Length of output: 159


🏁 Script executed:

# Check the complete stop_old_live_container function to confirm the behavior
sed -n '428,440p' scripts/server-zero-downtime-rollout.sh

Repository: ding113/claude-code-hub

Length of output: 503


🏁 Script executed:

# Verify the exact regex pattern matching in the find_container_by_host_port
# and see if there are any comments about port format
sed -n '353,358p' scripts/server-zero-downtime-rollout.sh

Repository: ding113/claude-code-hub

Length of output: 244


🏁 Script executed:

# Let's test what docker ps output actually shows for different port bindings
# by checking if there are any test or example outputs in the repo
rg -n '0\.0\.0\.0.*->|127\.0\.0\.1.*->' . --type sh --type txt | head -10

Repository: ding113/claude-code-hub

Length of output: 164


🏁 Script executed:

# Check the condition logic at line 602 more carefully to understand the fallback
sed -n '570,650p' scripts/server-zero-downtime-rollout.sh | grep -n 'LIVE_PORT\|STANDARD_PORT\|CONTAINER'

Repository: ding113/claude-code-hub

Length of output: 2446


🏁 Script executed:

# Let me verify what docker ps actually outputs for 0.0.0.0 binding
# by looking at any documentation or real examples
cat docker-compose.yaml | grep -A 5 'ports:'

Repository: ding113/claude-code-hub

Length of output: 316


🏁 Script executed:

# Check port_is_free function to understand how port detection works
rg -n 'port_is_free' scripts/server-zero-downtime-rollout.sh -A 8

Repository: ding113/claude-code-hub

Length of output: 2089


🏁 Script executed:

# Check if there's any alternative mechanism to stop containers after cutover
rg -n 'stop|container' scripts/server-zero-downtime-rollout.sh | grep -i 'old\|live\|previous' | head -20

Repository: ding113/claude-code-hub

Length of output: 1385


🏁 Script executed:

# Verify the exact docker ps output format by checking Docker documentation
# or understanding what format string {{.Ports}} produces
rg -n 'docker ps.*format' scripts/server-zero-downtime-rollout.sh -A 2 -B 2

Repository: ding113/claude-code-hub

Length of output: 639


find_container_by_host_port 仅匹配 127.0.0.1: 形式的端口绑定,与标准 compose 部署不兼容。

docker-compose.yaml 的端口映射 "${APP_PORT:-23000}:3000" 未指定主机 IP,默认绑定到 0.0.0.0,在 docker ps 输出中显示为 0.0.0.0:23000->3000/tcp。但脚本第 356 行的正则表达式仅匹配 127.0.0.1: 前缀,导致:

  • 第 556 行 LIVE_CONTAINER 在常见部署场景下被赋值为空字符串
  • 第 599、616、623、641 行 stop_old_live_container "$LIVE_CONTAINER" 接收空值时无法触发(第 430 行条件 [[ -n "$name" && "$KEEP_OLD_RUNNING" == false ]] 短路)
  • --stop-old-after-cutover 标志形同失效,用户期望的切换后停止旧容器功能被静默跳过,旧容器持续运行

修复建议:扩展匹配规则以覆盖 0.0.0.0、IPv6 等常见绑定格式:

建议修复
 find_container_by_host_port() {
   local port="$1"
   docker ps --format '{{.Names}}\t{{.Ports}}' | awk -v port="$port" '
-    $0 ~ ("127.0.0.1:" port "->") {print $1; exit}
+    $0 ~ ("[0-9.:\\[\\]]*" port "->") {print $1; exit}
   '
 }

或使用 Docker 内置过滤能力:docker ps --filter "publish=${port}"

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

In `@scripts/server-zero-downtime-rollout.sh` around lines 353 - 358, The function
find_container_by_host_port only matches "127.0.0.1:" and misses common bindings
like "0.0.0.0" or IPv6, causing LIVE_CONTAINER to be empty and
stop_old_live_container to be skipped; update find_container_by_host_port to
reliably locate the container by published port (either by using docker's
filtering: call docker ps --filter "publish=${port}" --format '{{.Names}}' and
return the first name, or broaden the awk/regex to match host prefixes like
0.0.0.0, 127.0.0.1 and ::/IPv6 forms), ensure the function returns a single
container name or empty string consistently so downstream checks in
stop_old_live_container and the --stop-old-after-cutover flag work as intended.

Comment thread src/actions/keys.ts
Comment on lines +879 to +946
const count = Number.isFinite(params.count) ? Math.trunc(params.count) : 0;
if (count < 1 || count > TEMPORARY_KEY_BATCH_MAX_COUNT) {
return {
ok: false,
error: `单次最多生成 ${TEMPORARY_KEY_BATCH_MAX_COUNT} 个临时 Key`,
errorCode: ERROR_CODES.INVALID_FORMAT,
};
}

const { findUserById } = await import("@/repository/user");
const user = await findUserById(params.userId);
if (!user) {
return {
ok: false,
error: tError("USER_NOT_FOUND"),
errorCode: ERROR_CODES.NOT_FOUND,
};
}

const normalizedGroupName = resolveTemporaryGroupName(user.providerGroup);
if (normalizedGroupName.length > TEMPORARY_GROUP_NAME_MAX_LENGTH) {
return {
ok: false,
error: `临时分组名称不能超过 ${TEMPORARY_GROUP_NAME_MAX_LENGTH} 个字符`,
errorCode: ERROR_CODES.INVALID_FORMAT,
};
}

const baseKey = await findKeyById(params.baseKeyId);
if (!baseKey || baseKey.userId !== params.userId) {
return {
ok: false,
error: tError("KEY_NOT_FOUND"),
errorCode: ERROR_CODES.NOT_FOUND,
};
}

const limitValidationError = validateTemporaryKeyLimitsAgainstUser(
user,
{
limit5hUsd: baseKey.limit5hUsd,
limitDailyUsd: baseKey.limitDailyUsd,
limitWeeklyUsd: baseKey.limitWeeklyUsd,
limitMonthlyUsd: baseKey.limitMonthlyUsd,
limitTotalUsd:
params.customLimitTotalUsd !== undefined
? params.customLimitTotalUsd
: (baseKey.limitTotalUsd ?? null),
limitConcurrentSessions: baseKey.limitConcurrentSessions,
},
tError
);
if (limitValidationError) {
return { ok: false, error: limitValidationError };
}

const existingKeys = await findKeyList(params.userId);
const createPayloads = buildTemporaryKeyCreatePayloads({
userId: params.userId,
baseKey,
existingKeys,
groupName: normalizedGroupName,
count,
customLimitTotalUsd: params.customLimitTotalUsd,
createKeyString: () => `sk-${randomBytes(16).toString("hex")}`,
});

const createdKeys = await createKeysBatch(createPayloads);
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

customLimitTotalUsd 缺少服务端格式校验,负数和 NaN 现在都能穿过去。

这里唯一的限制检查是“不能超过用户上限”,但 -1NaN 这类值不会命中这层判断,最后会直接进入 buildTemporaryKeyCreatePayloads() / createKeysBatch()。这会把非法额度写进临时 Key,或者在仓库层触发难以预期的错误。建议在进入限额比较前先显式校验它必须是有限数且 >= 0

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

In `@src/actions/keys.ts` around lines 879 - 946, The incoming
params.customLimitTotalUsd is not validated and can be NaN or negative before we
call validateTemporaryKeyLimitsAgainstUser and then pass it into
buildTemporaryKeyCreatePayloads/createKeysBatch; add a guard right after
computing count and before calling validateTemporaryKeyLimitsAgainstUser that,
when params.customLimitTotalUsd !== undefined, checks
Number.isFinite(params.customLimitTotalUsd) and params.customLimitTotalUsd >= 0
and otherwise returns { ok: false, error: <sensible message>, errorCode:
ERROR_CODES.INVALID_FORMAT } so only finite non-negative customLimitTotalUsd
values are allowed.

Comment thread src/actions/keys.ts
Comment on lines +1007 to +1019
const enabledCountInGroup = groupKeys.filter((key) => key.isEnabled).length;
if (enabledCountInGroup > 0) {
const activeKeyCount = await countActiveKeysByUser(params.userId);
if (activeKeyCount - enabledCountInGroup < 1) {
return {
ok: false,
error: tError("CANNOT_DISABLE_LAST_KEY"),
errorCode: ERROR_CODES.OPERATION_FAILED,
};
}
}

const deletedCount = await deleteKeysBatch(groupKeys.map((key) => key.id));
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

删除临时分组时,“不能删掉最后一个启用 Key”的保护不是原子的。

这里先读当前启用数,再单独执行 deleteKeysBatch(...)。如果并发有其他请求在这两个步骤之间禁用了/删除了别的 Key,这个检查就会被绕过,最终仍然可能把用户删到一个启用 Key 都不剩。这个校验和删除需要放进同一个事务里完成。

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

In `@src/actions/keys.ts` around lines 1007 - 1019, The non-atomic check+delete
can be racy: change the logic so the active-key count check and deleting
groupKeys happen inside a single DB transaction (use a transaction helper or
pass a trx to queries) instead of calling countActiveKeysByUser then
deleteKeysBatch separately; within the transaction, lock the user’s key rows
(e.g., SELECT ... FOR UPDATE or equivalent), recompute activeCount for
params.userId, verify activeCount - enabledCountInGroup >= 1 after excluding the
ids in groupKeys.map(k=>k.id), and only then call a transactional delete (e.g.,
deleteKeysBatchTransactional or deleteKeysBatch with trx) or abort/throw to
return the CANNOT_DISABLE_LAST_KEY error; update or add transactional variants
of countActiveKeysByUser/deleteKeysBatch if needed so the operation is atomic.

Comment thread src/actions/my-usage.ts
Comment on lines +674 to +676
const concurrentSessions = Math.max(keyConcurrent, userKeyConcurrent);
const concurrentSessionsLimit =
effectiveKeyConcurrentLimit > 0 ? effectiveKeyConcurrentLimit : null;
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

concurrentSessionsconcurrentSessionsLimit 现在可能不是同一维度。

concurrentSessions 取的是 Math.max(keyConcurrent, userKeyConcurrent),但 concurrentSessionsLimit 取的是 key/user 合成后的有效上限。这样在 “Key 上限更小、User 总会话更多” 的情况下,会出现类似 10 / 2 这种不可能成立的配对。这里需要像额度窗口一样,基于真正生效的限制来源返回对应的当前值。

Also applies to: 744-746

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

In `@src/actions/my-usage.ts` around lines 674 - 676, The current code mixes
dimensions: concurrentSessions uses Math.max(keyConcurrent, userKeyConcurrent)
while concurrentSessionsLimit uses effectiveKeyConcurrentLimit, which can
produce impossible pairs; fix by making concurrentSessions reflect the same
source as the effective limit — if effectiveKeyConcurrentLimit > 0 (key-level
limit active) set concurrentSessions = keyConcurrent and concurrentSessionsLimit
= effectiveKeyConcurrentLimit, otherwise set concurrentSessions =
userKeyConcurrent and concurrentSessionsLimit = effectiveUserConcurrentLimit (or
null if no user limit); apply the same change to the other occurrence referenced
(lines around the 744-746 block) and use the symbols concurrentSessions,
concurrentSessionsLimit, keyConcurrent, userKeyConcurrent,
effectiveKeyConcurrentLimit, and effectiveUserConcurrentLimit to locate and
adjust the logic.

Comment on lines +23 to +27
actions={
<Button asChild variant="outline" size="sm">
<Link href="#billing-correction">{t("config.form.billingCorrection.title")}</Link>
</Button>
}
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 确认 5 个语言文件均包含 config.form.billingCorrection.title
fd -t f -e json . messages | sort | xargs -I{} sh -c '
  echo "=== {} ===";
  jq -r "..|objects|select(has(\"billingCorrection\"))|.billingCorrection|keys?|.[]" "{}" 2>/dev/null | sort -u || true;
  jq -e "..|objects|.config?|.form?|.billingCorrection?|.title?" "{}" >/dev/null 2>&1 \
    && echo "  [OK] config.form.billingCorrection.title present" \
    || echo "  [MISSING] config.form.billingCorrection.title";
'

Repository: ding113/claude-code-hub

Length of output: 34998


需要在全部 5 个语言文件中补充 i18n 键 config.form.billingCorrection.title

代码在第 23-27 行引用了 t("config.form.billingCorrection.title"),但该键在 zh-CN、zh-TW、en、ja、ru 这 5 个语言文件的 messages/*/settings/config.json 中均缺失。在非默认语言下,该按钮会显示为字面量键名而非翻译文本,破坏用户体验。请在所有 5 个语言文件中添加此键和相应的翻译内容。

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

In `@src/app/`[locale]/settings/config/page.tsx around lines 23 - 27, The UI
references t("config.form.billingCorrection.title") inside the Button/Link block
(in page.tsx) but that i18n key is missing across all locales; add the key
"config.form.billingCorrection.title" with appropriate translations into each
locale's settings config JSON (messages/*/settings/config.json for zh-CN, zh-TW,
en, ja, ru) so t(...) returns the translated label instead of the raw key;
ensure the JSON key name matches exactly and include a concise translated string
for each language.

Comment on lines +684 to +697
const { route: downloadTemporaryKeyGroupRoute, handler: downloadTemporaryKeyGroupHandler } =
createActionRoute("keys", "downloadTemporaryKeyGroup", keyActions.downloadTemporaryKeyGroup, {
requestSchema: z.object({
userId: z.number().int().positive(),
groupName: z.string().trim().min(1).max(TEMPORARY_GROUP_NAME_MAX_LENGTH),
}),
responseSchema: z.string(),
description: "下载指定临时 Key 分组的纯文本 Key 列表",
summary: "下载临时 Key 分组",
tags: ["密钥管理"],
requiredRole: "admin",
argsMapper: (body) => [body],
});
app.openapi(downloadTemporaryKeyGroupRoute, downloadTemporaryKeyGroupHandler);
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep --pattern $'export async function downloadTemporaryKeyGroup($_) { $$$ }'
rg -nP -C3 'downloadTemporaryKeyGroup' --type=ts -g '!**/route.ts'

Repository: ding113/claude-code-hub

Length of output: 4340


🏁 Script executed:

#!/bin/bash
# Get the full implementation of downloadTemporaryKeyGroup
sed -n '1036,1100p' src/actions/keys.ts

# Search for ActionResult type definition
rg -n 'type ActionResult|interface ActionResult' --type=ts

Repository: ding113/claude-code-hub

Length of output: 2135


🏁 Script executed:

#!/bin/bash
# Find createActionRoute definition
rg -n 'export.*createActionRoute|function createActionRoute' --type=ts -A 20 | head -60

# Also get the ActionResult type definition
cat src/actions/types.ts | head -50

Repository: ding113/claude-code-hub

Length of output: 3844


🏁 Script executed:

#!/bin/bash
# Get more of the createActionRoute function to see the handler implementation
sed -n '255,350p' src/lib/api/action-adapter-openapi.ts

Repository: ding113/claude-code-hub

Length of output: 2844


🏁 Script executed:

#!/bin/bash
# Continue reading the handler implementation
sed -n '330,400p' src/lib/api/action-adapter-openapi.ts

Repository: ding113/claude-code-hub

Length of output: 2481


🏁 Script executed:

#!/bin/bash
# Continue reading to see the response return logic
sed -n '400,430p' src/lib/api/action-adapter-openapi.ts

Repository: ding113/claude-code-hub

Length of output: 983


响应 Schema 不匹配实际 API 返回结构

downloadTemporaryKeyGroup 函数返回 Promise<ActionResult<string>>,通过 createActionRoute 处理后,实际的 API 响应结构为:

  • 成功: { ok: true, data: "string content" }
  • 失败: { ok: false, error: "...", errorCode?: "..." }

但当前声明的 responseSchema: z.string() 只定义了纯字符串,与实际的包装结构不符,导致 OpenAPI 文档与实际响应不匹配,客户端按 schema 解析会失败。应更新 responseSchema 以准确反映实际的 API 返回结构。

🤖 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 684 - 697, The
responseSchema for downloadTemporaryKeyGroupRoute is currently z.string() but
the handler returns Promise<ActionResult<string>>, so update the responseSchema
passed to createActionRoute to reflect the ActionResult<string> wrapper (i.e.,
an object with ok boolean and either data: string on success or error and
optional errorCode on failure) so the OpenAPI output matches
downloadTemporaryKeyGroup's actual responses produced by
keyActions.downloadTemporaryKeyGroup.

Comment on lines +1 to +4
export { GET } from "@/app/api/public-status/route";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 检查 public-status route 是否声明了相同的 runtime / dynamic
fd -t f 'route.(ts|tsx|js|mjs)$' src/app/api/public-status | xargs -I{} sh -c 'echo "=== {} ==="; cat "{}"'

Repository: ding113/claude-code-hub

Length of output: 2470


删除别名路由 /api/status,或在 src/app/api/public-status/route.ts 中添加匹配的 runtimedynamic 声明

src/app/api/public-status/route.ts 未声明 runtimedynamic,而 src/app/api/status/route.ts 明确设置为 runtime = "nodejs"dynamic = "force-dynamic"。这导致两个路由的行为不一致:/api/status 使用 Node.js 运行时并强制动态渲染,而 /api/public-status 使用 Next.js 默认配置。需要:

  1. 在源路由 src/app/api/public-status/route.ts 中添加相同的常量声明:

    export const runtime = "nodejs";
    export const dynamic = "force-dynamic";
    
  2. 或者,若此别名路由无外部使用证据,应移除 src/app/api/status/route.ts

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

In `@src/app/api/status/route.ts` around lines 1 - 4, The alias route re-exports
GET but sets runtime = "nodejs" and dynamic = "force-dynamic" while the original
handler (exported by the GET in public-status route) lacks those declarations,
causing inconsistent behavior; either add matching exports in the source route
by exporting the same constants (export const runtime = "nodejs"; export const
dynamic = "force-dynamic";) in the module that defines GET (the public-status
route), or remove the alias route that re-exports GET (the route that does
`export { GET } from ...`) if the alias isn’t needed—update the module that
defines GET (the public-status route) or delete the alias file accordingly.

Comment thread src/repository/key.ts
Comment on lines +491 to +539
export async function createKeysBatch(keyDataList: CreateKeyData[]): Promise<Key[]> {
if (keyDataList.length === 0) return [];

const rows = keyDataList.map((keyData) => ({
userId: keyData.user_id,
key: keyData.key,
name: keyData.name,
isEnabled: keyData.is_enabled,
expiresAt: keyData.expires_at,
canLoginWebUi: keyData.can_login_web_ui ?? true,
limit5hUsd: keyData.limit_5h_usd != null ? keyData.limit_5h_usd.toString() : null,
limitDailyUsd: keyData.limit_daily_usd != null ? keyData.limit_daily_usd.toString() : null,
dailyResetMode: keyData.daily_reset_mode ?? "fixed",
dailyResetTime: keyData.daily_reset_time ?? "00:00",
limitWeeklyUsd: keyData.limit_weekly_usd != null ? keyData.limit_weekly_usd.toString() : null,
limitMonthlyUsd:
keyData.limit_monthly_usd != null ? keyData.limit_monthly_usd.toString() : null,
limitTotalUsd: keyData.limit_total_usd != null ? keyData.limit_total_usd.toString() : null,
costResetAt: keyData.cost_reset_at ?? null,
limitConcurrentSessions: keyData.limit_concurrent_sessions,
providerGroup: keyData.provider_group ?? null,
cacheTtlPreference: keyData.cache_ttl_preference ?? null,
temporaryGroupName: keyData.temporary_group_name ?? null,
}));

const inserted = await db.insert(keys).values(rows).returning({
id: keys.id,
userId: keys.userId,
key: keys.key,
name: keys.name,
isEnabled: keys.isEnabled,
expiresAt: keys.expiresAt,
canLoginWebUi: keys.canLoginWebUi,
limit5hUsd: keys.limit5hUsd,
limitDailyUsd: keys.limitDailyUsd,
dailyResetMode: keys.dailyResetMode,
dailyResetTime: keys.dailyResetTime,
limitWeeklyUsd: keys.limitWeeklyUsd,
limitMonthlyUsd: keys.limitMonthlyUsd,
limitTotalUsd: keys.limitTotalUsd,
costResetAt: keys.costResetAt,
limitConcurrentSessions: keys.limitConcurrentSessions,
providerGroup: keys.providerGroup,
cacheTtlPreference: keys.cacheTtlPreference,
temporaryGroupName: keys.temporaryGroupName,
createdAt: keys.createdAt,
updatedAt: keys.updatedAt,
deletedAt: keys.deletedAt,
});
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 | 🔴 Critical

createKeysBatch 漏写 limit5hResetMode,与 createKey 不一致

createKey(第 154 行)会显式写入 limit5hResetMode: keyData.limit_5h_reset_mode ?? "rolling",并在 returning 中(第 178 行)回读该列;但 createKeysBatchvalues(第 494-514 行)和 returning(第 516-539 行)都遗漏了 limit5hResetMode。这会导致:

  1. 通过批量创建的 key 在数据库中 limit5hResetMode 取列默认值,调用方传入的 limit_5h_reset_mode 被静默丢弃;
  2. inserted.map(toKey) 得到的 Key 对象上 limit5hResetModeundefined,对依赖该字段做 5h 限额结算/前端展示的下游逻辑(如 validateApiKeyAndGetUser join 路径已正确返回 keyLimit5hResetMode)形成数据不一致。

由于本 PR 的临时 key 组功能依赖 createKeysBatch 批量创建,这个回归会直接体现在临时 key 上。

建议修复
   const rows = keyDataList.map((keyData) => ({
     userId: keyData.user_id,
     key: keyData.key,
     name: keyData.name,
     isEnabled: keyData.is_enabled,
     expiresAt: keyData.expires_at,
     canLoginWebUi: keyData.can_login_web_ui ?? true,
     limit5hUsd: keyData.limit_5h_usd != null ? keyData.limit_5h_usd.toString() : null,
+    limit5hResetMode: keyData.limit_5h_reset_mode ?? "rolling",
     limitDailyUsd: keyData.limit_daily_usd != null ? keyData.limit_daily_usd.toString() : null,
     dailyResetMode: keyData.daily_reset_mode ?? "fixed",
     ...
   }));

   const inserted = await db.insert(keys).values(rows).returning({
     id: keys.id,
     ...
     limit5hUsd: keys.limit5hUsd,
+    limit5hResetMode: keys.limit5hResetMode,
     limitDailyUsd: keys.limitDailyUsd,
     dailyResetMode: keys.dailyResetMode,
     ...
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/repository/key.ts` around lines 491 - 539, createKeysBatch is missing the
limit5hResetMode column in both the rows mapped for db.insert (values) and the
returning projection, causing incoming limit_5h_reset_mode to be dropped and
inserted rows to not return that field; update createKeysBatch to set
limit5hResetMode: keyData.limit_5h_reset_mode ?? "rolling" in the rows mapping
(like createKey does) and add limit5hResetMode to the returning object so
inserted results include it (ensure toKey or any consumer reads the returned
limit5hResetMode).

@ding113
Copy link
Copy Markdown
Owner

ding113 commented Apr 25, 2026

您好,PR 请提交到 dev 分支。非常感谢。并且建议在 PR 大型功能前通过 Issue 区或交流群讨论。

@dofastted
Copy link
Copy Markdown
Contributor Author

Split follow-up PRs targeting dev:\n\n- #1111: self-service quota windows and readonly Bearer quota access\n- #1112: user-to-key configuration sync\n\nBoth are based on upstream dev and intentionally exclude the rest of #1106 so each review has a narrower scope.

@dofastted
Copy link
Copy Markdown
Contributor Author

本次已把 #1106 里的功能先拆成两个面向 dev 的小 PR,并按初轮 review 继续修过一版:\n\n- #1111 Self-service quota windows:给 API key 自助查询额度用。 会返回 5h / daily / weekly / monthly / total 各时间窗口的 limit、used、remaining 和百分比;readonly Bearer key 可以访问自助额度接口,但会隐藏 allowed models / clients 和日志 endpoint,并通过 明确告诉调用方哪些字段被隐藏。\n- #1112 User-to-key config sync:给管理员把用户级限额、provider group、5h reset mode、daily reset 配置同步到该用户已有 keys。单用户编辑和批量编辑都可以触发,默认 key 创建时也使用最终保存后的用户配置。缓存清理失败现在会记录 warn,不再静默吞掉。\n\n这两个 PR 都只保留各自功能需要的文件,暂时没有混入 temporary key groups、billing multiplier、status alias 或部署脚本,方便 dev 分支先单独 review。

@dofastted
Copy link
Copy Markdown
Contributor Author

更正上一条说明中的代码名格式:\n\n本次已把 #1106 里的功能先拆成两个面向 dev 的小 PR,并按初轮 review 继续修过一版。\n\n- #1111 Self-service quota windows:给 API key 自助查询额度用。getMyQuota 会返回 5h / daily / weekly / monthly / total 各时间窗口的 limit、used、remaining 和百分比;readonly Bearer key 可以访问自助额度接口,但会隐藏 allowed models / clients 和日志 endpoint,并通过 readonlyRedactedFields 明确告诉调用方哪些字段被隐藏。\n- #1112 User-to-key config sync:给管理员把用户级限额、provider group、limit5hResetMode、daily reset 配置同步到该用户已有 keys。单用户编辑和批量编辑都可以触发,默认 key 创建时也使用最终保存后的用户配置。缓存清理失败现在会记录 warn,不再静默吞掉。\n\n这两个 PR 都只保留各自功能需要的文件,暂时没有混入 temporary key groups、billing multiplier、status alias 或部署脚本,方便 dev 分支先单独 review。

@ding113 ding113 changed the base branch from main to dev April 28, 2026 02:49
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: Backlog

Development

Successfully merging this pull request may close these issues.

2 participants