feat: add temporary key groups and self-service quota windows#1106
feat: add temporary key groups and self-service quota windows#1106dofastted wants to merge 15 commits into
Conversation
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>
📝 Walkthrough功能概览添加临时密钥分组功能(包括批量创建、删除、下载临时API密钥),引入成本乘数校正系统设置,实现用户配置同步到密钥,增强配额/使用量追踪窗口,以及支持多语言的用户界面更新。 变更清单
代码审查难度估计🎯 4 (复杂) | ⏱️ ~60 分钟 可能相关的 PR
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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 }forapiKeyVacuumFilter.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
anybypass - Documentation accuracy - Found one misleading comment
- Test coverage - Flagged potential gap
- Code clarity - Good overall structure
Positive Observations
- Good error handling in auth layer:
src/app/v1/_lib/proxy/auth-guard.tsproperly logs and categorizes authentication failures. - Systematic cache fallback:
src/lib/config/system-settings-cache.tsproperly implements fail-soft behavior with stale cache fallback. - Comprehensive schema definitions: New fields in schema.ts have proper defaults and indexes.
- i18n consistency: Most new UI strings use next-intl translations (5 languages).
Automated review by Claude AI
There was a problem hiding this comment.
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.
| 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, | ||
| }; |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
| limitWeeklyUsd: amountByField.limitWeeklyUsd.values[index] ?? null, | |
| limitConcurrentSessions: concurrent.values[index], |
| limitWeeklyUsd: number | null; | ||
| limitMonthlyUsd: number | null; | ||
| limitTotalUsd: number | null; | ||
| limitConcurrentSessions: number; |
| 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, | ||
| }; |
There was a problem hiding this comment.
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.
| 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, | |
| }; |
| .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))); | ||
| } |
There was a problem hiding this comment.
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.
| await Promise.all([ | ||
| ...requestedIds.map((userId) => invalidateCachedUser(userId).catch(() => {})), | ||
| ...keyStringsForCache.map((key) => invalidateCachedKey(key).catch(() => {})), | ||
| ]); |
There was a problem hiding this comment.
| -e DSN="$dsn" \ | ||
| -e REDIS_URL="$redis_url" \ |
There was a problem hiding this comment.
| return { | ||
| values: Array.from({ length: keyCount }, (_, index) => | ||
| index < totalCents ? centsToUsd(1) : null | ||
| ), | ||
| discarded: 0, | ||
| }; |
There was a problem hiding this 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.
| 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.| return { | ||
| ok: false, | ||
| error: tError("PERMISSION_DENIED"), | ||
| errorCode: ERROR_CODES.PERMISSION_DENIED, | ||
| }; | ||
| } | ||
|
|
There was a problem hiding this 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.
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.| return validateKey(token, options); | ||
| } | ||
|
|
||
| if (options?.allowReadOnlyAccess && tokenKind === "legacy") { |
There was a problem hiding this 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).
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.There was a problem hiding this comment.
💡 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".
| 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", |
There was a problem hiding this comment.
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 👍 / 👎.
| requestSchema: z.object({ | ||
| userId: z.number().int().positive(), | ||
| ...UpdateUserSchema.shape, | ||
| }), |
There was a problem hiding this comment.
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 👍 / 👎.
| return { | ||
| ok: false, | ||
| error: `单次最多生成 ${TEMPORARY_KEY_BATCH_MAX_COUNT} 个临时 Key`, | ||
| errorCode: ERROR_CODES.INVALID_FORMAT, |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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多个语言文件中存在重复键。
publicStatusProjectionWarning和publicStatusBackgroundRefreshPending在多个 locale 的settings/config.json中重复定义:
messages/ru/settings/config.json(第 45–46 行 vs 第 98–99 行)messages/zh-TW/settings/config.jsonmessages/ja/settings/config.jsonJSON 中重复键属未定义行为,多数解析器采用「后者覆盖前者」,但会导致静默数据丢失和维护混乱。需删除重复项(建议保留靠近
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接口也未定义此字段。相比之下,dailyResetMode和dailyResetTime被完整纳入同步机制(行 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.syncKeys、batchEdit.confirm.syncKeysDescription、batchEdit.toast.keysSynced、batchEdit.toast.syncFailed、batchEdit.user.fields.syncKeys、batchEdit.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 Keys、This will overwrite...、{count} keys、Temp等。这样上线后日语用户会在新功能里直接看到半英半日的界面。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新增了providerGroup、limitTotalUsd、limitConcurrentSessions、dailyResetMode、dailyResetTime五个字段进入校验,但第 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)未返回时,对应的*.remainingPercent为undefined,拼接后会得到"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.count与toasts.deleteSuccess中的 "keys" 未本地化
"{count} keys"与"Группа {group} удалена ({count} keys)"中嵌入了英文单词 "keys"。建议替换为俄语ключей(或ключи,根据复数规则使用 ICUplural),保持与同文件其它术语一致。建议改动
- "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会被原样转交给batchSyncUserConfigToKeys。batchSyncUserConfigToKeys确实会处理这些字段(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都会回退为{},其remainingPercent为undefined,最终extra会拼出形如"5H剩余:undefined%/日剩余:undefined%..."的串,直接在第三方工具里给用户看,体验不佳;和同 PR 里api-key-quota-extractor-weekly.js用toNumber兜底的做法也不一致。建议为 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文档中明确说明这种回退场景下
limitTotalUsd、usedTotalUsd语义的定义。🤖 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.5、2.9这类值会通过前端校验,但服务端会在createTemporaryKeysBatch里Math.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"),但此处直接传递validatedData给buildFirstSyncedKeyConfig。如果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/lock或XDG_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
📒 Files selected for processing (92)
.env.example.gitignoreDockerfileREADME.en.mdREADME.mddev/docker-compose.yamldocker-compose.yamldocs/examples/api-key-quota-extractor-compatible.jsdocs/examples/api-key-quota-extractor-daily.jsdocs/examples/api-key-quota-extractor-direct.jsdocs/examples/api-key-quota-extractor-total.jsdocs/examples/api-key-quota-extractor-weekly.jsdocs/examples/api-key-quota-extractor.jsdocs/examples/temporary-key-groups.mddrizzle/0099_powerful_micromax.sqldrizzle/meta/0099_snapshot.jsondrizzle/meta/_journal.jsonmessages/en/dashboard.jsonmessages/en/settings/config.jsonmessages/ja/dashboard.jsonmessages/ja/settings/config.jsonmessages/ru/dashboard.jsonmessages/ru/settings/config.jsonmessages/zh-CN/dashboard.jsonmessages/zh-CN/settings/config.jsonmessages/zh-TW/dashboard.jsonmessages/zh-TW/settings/config.jsonplan/2026-04-25_18-53-35-cch.mdpublic/examples/api-key-quota-extractor-compatible.jspublic/examples/api-key-quota-extractor-direct.jspublic/examples/api-key-quota-extractor.jsscripts/deploy.ps1scripts/deploy.shscripts/server-zero-downtime-rollout.shsrc/actions/keys.tssrc/actions/my-usage.tssrc/actions/system-config.tssrc/actions/users.tssrc/app/[locale]/dashboard/_components/dashboard-header.tsxsrc/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsxsrc/app/[locale]/dashboard/_components/user/batch-edit/batch-user-section.tsxsrc/app/[locale]/dashboard/_components/user/create-user-dialog.tsxsrc/app/[locale]/dashboard/_components/user/edit-user-dialog.tsxsrc/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsxsrc/app/[locale]/dashboard/_components/user/temporary-key-batch-dialog.tsxsrc/app/[locale]/dashboard/_components/user/user-key-table-row.tsxsrc/app/[locale]/dashboard/_components/user/user-management-table.tsxsrc/app/[locale]/dashboard/users/users-page-client.tsxsrc/app/[locale]/settings/config/_components/system-settings-form.tsxsrc/app/[locale]/settings/config/page.tsxsrc/app/api/actions/[...route]/route.tssrc/app/api/admin/system-config/route.tssrc/app/api/status/route.tssrc/app/v1/_lib/proxy/auth-guard.tssrc/app/v1/_lib/proxy/provider-selector.tssrc/app/v1/_lib/proxy/session.tssrc/drizzle/schema.tssrc/lib/auth.tssrc/lib/auth/public-path-policy.tssrc/lib/auth/readonly-access.tssrc/lib/availability/availability-service.tssrc/lib/availability/types.tssrc/lib/billing/cost-multiplier.tssrc/lib/config/system-settings-cache.tssrc/lib/keys/temporary-key-groups.tssrc/lib/my-usage/readonly-redaction.tssrc/lib/users/user-key-sync.tssrc/lib/validation/schemas.tssrc/proxy.tssrc/repository/_shared/transformers.tssrc/repository/key.tssrc/repository/system-config.tssrc/types/key.tssrc/types/system-config.tssrc/types/user.tstests/api/api-actions-integrity.test.tstests/api/my-usage-readonly.test.tstests/configs/my-usage.config.tstests/unit/actions/my-usage-concurrent-inherit.test.tstests/unit/actions/temporary-keys.test.tstests/unit/actions/users-key-sync.test.tstests/unit/actions/users-reset-all-statistics.test.tstests/unit/api/status-route-alias.test.tstests/unit/auth/auth-scoped-session-branches.test.tstests/unit/lib/billing/cost-multiplier.test.tstests/unit/lib/config/system-settings-cache.test.tstests/unit/lib/keys/temporary-key-groups.test.tstests/unit/lib/users/user-key-sync.test.tstests/unit/proxy/auth-guard.test.tstests/unit/proxy/proxy-auth-cookie-passthrough.test.tstests/unit/user-dialogs.test.tsxtests/vitest.base.ts
| "usageLogs": "Usage Logs", | ||
| "leaderboard": "Leaderboard", | ||
| "availability": "Availability", | ||
| "systemStatus": "System Status", |
There was a problem hiding this comment.
🧩 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
doneRepository: ding113/claude-code-hub
Length of output: 13251
修复 i18n 多语言同步不完整问题
验证发现 temporaryKeys 和 syncKeys 相关的 i18n 键在 5 个语言文件中同步不完整。具体来说:
messages/en/dashboard.json和messages/zh-CN/dashboard.json包含全部 52 个键messages/ja/dashboard.json、messages/ru/dashboard.json和messages/zh-TW/dashboard.json各缺少以下 8 个键:userManagement.temporaryKeys.listActions.showLessuserManagement.temporaryKeys.listActions.showMoreuserManagement.temporaryKeys.sections.standard.descriptionuserManagement.temporaryKeys.sections.standard.emptyuserManagement.temporaryKeys.sections.standard.titleuserManagement.temporaryKeys.sections.temporary.descriptionuserManagement.temporaryKeys.sections.temporary.emptyuserManagement.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.
| "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." | ||
| }, |
There was a problem hiding this comment.
俄语本地化文件中残留英文文案。
billingCorrection.title 和 billingCorrection.description 在 messages/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.
| "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.
| 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} | ||
| ' | ||
| } |
There was a problem hiding this comment.
🧩 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' -C2Repository: 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 8Repository: 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 -20Repository: 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 -20Repository: 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 -30Repository: 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 -20Repository: 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 8Repository: 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 2Repository: 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 -20Repository: 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 -40Repository: 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.shRepository: 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.shRepository: 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 -40Repository: 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 -20Repository: 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.shRepository: 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.shRepository: 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 -10Repository: 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 8Repository: 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 -20Repository: 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 2Repository: 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.
| 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); |
There was a problem hiding this comment.
customLimitTotalUsd 缺少服务端格式校验,负数和 NaN 现在都能穿过去。
这里唯一的限制检查是“不能超过用户上限”,但 -1、NaN 这类值不会命中这层判断,最后会直接进入 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.
| 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)); |
There was a problem hiding this comment.
删除临时分组时,“不能删掉最后一个启用 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.
| const concurrentSessions = Math.max(keyConcurrent, userKeyConcurrent); | ||
| const concurrentSessionsLimit = | ||
| effectiveKeyConcurrentLimit > 0 ? effectiveKeyConcurrentLimit : null; |
There was a problem hiding this comment.
concurrentSessions 和 concurrentSessionsLimit 现在可能不是同一维度。
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.
| actions={ | ||
| <Button asChild variant="outline" size="sm"> | ||
| <Link href="#billing-correction">{t("config.form.billingCorrection.title")}</Link> | ||
| </Button> | ||
| } |
There was a problem hiding this comment.
🧩 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.
| 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); |
There was a problem hiding this comment.
🧩 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=tsRepository: 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 -50Repository: 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.tsRepository: 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.tsRepository: 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.tsRepository: 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.
| export { GET } from "@/app/api/public-status/route"; | ||
|
|
||
| export const runtime = "nodejs"; | ||
| export const dynamic = "force-dynamic"; |
There was a problem hiding this comment.
🧩 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 中添加匹配的 runtime 和 dynamic 声明
src/app/api/public-status/route.ts 未声明 runtime 或 dynamic,而 src/app/api/status/route.ts 明确设置为 runtime = "nodejs" 和 dynamic = "force-dynamic"。这导致两个路由的行为不一致:/api/status 使用 Node.js 运行时并强制动态渲染,而 /api/public-status 使用 Next.js 默认配置。需要:
-
在源路由
src/app/api/public-status/route.ts中添加相同的常量声明:export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -
或者,若此别名路由无外部使用证据,应移除
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.
| 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, | ||
| }); |
There was a problem hiding this comment.
createKeysBatch 漏写 limit5hResetMode,与 createKey 不一致
createKey(第 154 行)会显式写入 limit5hResetMode: keyData.limit_5h_reset_mode ?? "rolling",并在 returning 中(第 178 行)回读该列;但 createKeysBatch 的 values(第 494-514 行)和 returning(第 516-539 行)都遗漏了 limit5hResetMode。这会导致:
- 通过批量创建的 key 在数据库中
limit5hResetMode取列默认值,调用方传入的limit_5h_reset_mode被静默丢弃; inserted.map(toKey)得到的Key对象上limit5hResetMode为undefined,对依赖该字段做 5h 限额结算/前端展示的下游逻辑(如validateApiKeyAndGetUserjoin 路径已正确返回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).
|
您好,PR 请提交到 dev 分支。非常感谢。并且建议在 PR 大型功能前通过 Issue 区或交流群讨论。 |
|
本次已把 #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。 |
|
更正上一条说明中的代码名格式:\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。 |
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.src/lib/keys/temporary-key-groups.ts-- group name resolution, batch payload builder, limit validationsrc/actions/keys.ts:createTemporaryKeysBatch,removeTemporaryKeyGroup,downloadTemporaryKeyGroupcreateKeysBatch,deleteKeysBatchfor bulk key operationstemporary-key-batch-dialog.tsxwith creation dialog, group display, and downloaduser-key-table-row.tsxreorganized into standard/temporary key sections0099): addstemporary_group_namecolumn onkeyswith partial indexidx_keys_user_temporary_groupdocs/examples/temporary-key-groups.mdwith curl examples for all three operations2. Self-Service Quota Windows
The
getMyQuotaendpoint now returns precomputedquotaWindows(5h, daily, weekly, monthly, total) withlimitUsd,usedUsd,remainingUsd,usedPercent,remainingPercent,isUnlimited, andisExhaustedper window. OverallremainingandremainingPercentfields are the minimum across all bounded windows.MyUsageQuotaWindowwith period-scoped computed fieldsresolveEffectiveQuotaWindow,buildQuotaWindow,resolveTotalLimitWithMonthlyFallbacksrc/lib/my-usage/readonly-redaction.tsstrips sensitive fields from quota/logs when called with a readonly Bearer key3. 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.
src/lib/users/user-key-sync.ts--buildSyncedKeyConfigs,buildFirstSyncedKeyConfigsrc/actions/users.ts:syncUserConfigToKeys(single user),batchSyncUserConfigToKeys(batch)provider_group4. Readonly Bearer API Key Access
Endpoints using
allowReadOnlyAccessnow accept Bearer API keys even when the proxy is in opaque session-token mode, enabling self-service quota APIs without requiring a login cookie.src/lib/auth/readonly-access.tssrc/lib/auth/public-path-policy.tsfor public route handlingsrc/app/v1/_lib/proxy/auth-guard.tsaccepts Bearer keys in opaque auth mode5. 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.src/lib/billing/cost-multiplier.tscost_multiplier_correctioncolumn onsystem_settings(default 0, NOT NULL)src/app/v1/_lib/proxy/provider-selector.ts6. Status Route Aliases
Adds
/api/statusand dashboard header link to the existing public system status page, preserving the current/api/system-statusimplementation.src/app/api/status/route.ts(alias)7. Infrastructure & DevOps
scripts/server-zero-downtime-rollout.sh-- blue-green Docker rollout with nginx cutover, backup/rollback, and dry-run supportcurlpackage for health checkscurlwithnode -e fetch(...)in all compose files and deploy scripts (removes curl dependency)NODE_OPTIONS=--max-old-space-size=1536,HOST=0.0.0.0,HOSTNAME=0.0.0.0.gitignore: added.runtime/directory8. 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
keystemporary_group_namevarchar(120)(user_id, temporary_group_name) WHERE deleted_at IS NULL AND temporary_group_name IS NOT NULLsystem_settingscost_multiplier_correctionnumeric(10,4)0Migration file:
drizzle/0099_powerful_micromax.sqlBreaking Changes
None. All new columns are nullable or have safe defaults. New response fields on
getMyQuotaare additive -- existing consumers are unaffected.Testing
Automated Tests (8 new test files)
tests/unit/actions/temporary-keys.test.ts-- temporary key batch create/remove/downloadtests/unit/actions/users-key-sync.test.ts-- user-to-key sync actionstests/unit/lib/keys/temporary-key-groups.test.ts-- group name resolution, payload buildingtests/unit/lib/users/user-key-sync.test.ts-- config distribution logictests/unit/lib/billing/cost-multiplier.test.ts-- multiplier correctiontests/unit/proxy/auth-guard.test.ts-- Bearer key in opaque auth modetests/unit/auth/auth-scoped-session-branches.test.ts-- session branch coveragetests/unit/api/status-route-alias.test.ts-- status route aliasUpdated Tests
tests/api/my-usage-readonly.test.ts-- readonly Bearer quota regressiontests/unit/actions/my-usage-concurrent-inherit.test.ts-- quota window inheritancetests/unit/user-dialogs.test.tsx-- temporary key dialog renderingtests/api/api-actions-integrity.test.ts-- new action registrationVerification
Note
Description enhanced by Claude AI