From a66fbe93b6fea34be88b6a6c0000eff1a0be6424 Mon Sep 17 00:00:00 2001 From: mci77777 Date: Sun, 26 Apr 2026 12:52:39 +0800 Subject: [PATCH] feat(users): sync user limits to keys --- messages/en/dashboard.json | 16 +- messages/en/settings/config.json | 4 + messages/ja/dashboard.json | 16 +- messages/ja/settings/config.json | 4 + messages/ru/dashboard.json | 16 +- messages/ru/settings/config.json | 4 + messages/zh-CN/dashboard.json | 16 +- messages/zh-CN/settings/config.json | 4 + messages/zh-TW/dashboard.json | 16 +- messages/zh-TW/settings/config.json | 4 + src/actions/users.ts | 560 +++++++++++++++++- .../user/batch-edit/batch-edit-dialog.tsx | 118 ++-- .../user/batch-edit/batch-user-section.tsx | 14 + .../_components/user/create-user-dialog.tsx | 38 +- .../_components/user/edit-user-dialog.tsx | 59 +- .../user/forms/key-edit-section.tsx | 76 +-- .../_components/system-settings-form.tsx | 150 ++--- src/app/[locale]/settings/config/page.tsx | 7 + src/lib/users/user-key-sync.ts | 192 ++++++ tests/unit/actions/users-key-sync.test.ts | 325 ++++++++++ .../users-reset-all-statistics.test.ts | 1 + tests/unit/lib/users/user-key-sync.test.ts | 68 +++ tests/unit/user-dialogs.test.tsx | 26 +- 23 files changed, 1555 insertions(+), 179 deletions(-) create mode 100644 src/lib/users/user-key-sync.ts create mode 100644 tests/unit/actions/users-key-sync.test.ts create mode 100644 tests/unit/lib/users/user-key-sync.test.ts diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 21db4cc9d..aa172d03d 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -1639,6 +1639,12 @@ "deleteFailed": "Failed to delete user", "userDeleted": "User has been deleted", "saving": "Saving...", + "syncKeys": { + "button": "Sync to Keys", + "loading": "Syncing...", + "success": "Synced to {count} keys", + "error": "Failed to sync keys" + }, "resetSection": { "title": "Reset Options" }, @@ -1697,6 +1703,8 @@ "title": "Confirm Batch Update", "description": "This will update {users} users and {keys} keys. This action cannot be undone.", "userFields": "User Fields", + "syncKeys": "Sync to Keys", + "syncKeysDescription": "This will overwrite all undeleted keys for these {users} users from their current user settings.", "keyFields": "Key Fields", "goBack": "Go Back", "update": "Confirm Update", @@ -1705,8 +1713,10 @@ "toast": { "usersUpdated": "Updated {count} users", "keysUpdated": "Updated {count} keys", + "keysSynced": "Synced {keys} keys for {users} users", "usersFailed": "User update failed: {error}", "keysFailed": "Key update failed: {error}", + "syncFailed": "Sync to keys failed: {error}", "batchFailed": "Batch update failed" }, "validation": { @@ -1728,12 +1738,14 @@ "limit5h": "5h Limit (USD)", "limitDaily": "Daily Limit (USD)", "limitWeekly": "Weekly Limit (USD)", - "limitMonthly": "Monthly Limit (USD)" + "limitMonthly": "Monthly Limit (USD)", + "syncKeys": "Sync to Keys" }, "placeholders": { "emptyToClear": "Leave empty to clear", "tagsPlaceholder": "Press enter to add, comma-separated", - "emptyNoLimit": "Leave empty for no limit" + "emptyNoLimit": "Leave empty for no limit", + "syncKeysDescription": "When enabled, user fields from this batch edit are saved first, then all undeleted keys for each user are overwritten from that user's settings." } }, "key": { diff --git a/messages/en/settings/config.json b/messages/en/settings/config.json index 0a102133b..92cad85ab 100644 --- a/messages/en/settings/config.json +++ b/messages/en/settings/config.json @@ -13,6 +13,10 @@ "redirected": "After Redirection (Actual Model)" }, "billingModelSourcePlaceholder": "Select billing model source", + "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." + }, "codexPriorityBillingSource": "Codex Priority Billing Source", "codexPriorityBillingSourceDesc": "Controls which service_tier is used for Codex Priority (Fast Mode) surcharge billing. The default is Requested Service Tier; if Actual Service Tier is selected, the response value is used first and falls back to the request value when the response omits it.", "codexPriorityBillingSourceOptions": { diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index f3b3a9561..1ab655c8a 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -1617,6 +1617,12 @@ "deleteFailed": "ユーザーの削除に失敗しました", "userDeleted": "ユーザーが削除されました", "saving": "保存しています...", + "syncKeys": { + "button": "Key に同期", + "loading": "同期中...", + "success": "{count} 個の Key に同期しました", + "error": "Key の同期に失敗しました" + }, "resetSection": { "title": "リセットオプション" }, @@ -1675,6 +1681,8 @@ "title": "一括更新を確認", "description": "{users} ユーザーと {keys} キーを更新します。この操作は元に戻せません。", "userFields": "ユーザーフィールド", + "syncKeys": "キーへ同期", + "syncKeysDescription": "現在のユーザー設定を使って、選択した {users} ユーザーの削除されていないすべてのキーを上書きします。", "keyFields": "キーフィールド", "goBack": "戻って修正", "update": "更新を確定", @@ -1683,8 +1691,10 @@ "toast": { "usersUpdated": "{count} ユーザーを更新しました", "keysUpdated": "{count} キーを更新しました", + "keysSynced": "{users} ユーザーの {keys} キーを同期しました", "usersFailed": "ユーザーの更新に失敗しました: {error}", "keysFailed": "キーの更新に失敗しました: {error}", + "syncFailed": "キーへの同期に失敗しました: {error}", "batchFailed": "一括更新に失敗しました" }, "validation": { @@ -1706,12 +1716,14 @@ "limit5h": "5時間上限 (USD)", "limitDaily": "日次上限 (USD)", "limitWeekly": "週次上限 (USD)", - "limitMonthly": "月次上限 (USD)" + "limitMonthly": "月次上限 (USD)", + "syncKeys": "Sync to Keys" }, "placeholders": { "emptyToClear": "空欄でクリア", "tagsPlaceholder": "Enterで追加、カンマ区切り対応", - "emptyNoLimit": "空欄で制限なし" + "emptyNoLimit": "空欄で制限なし", + "syncKeysDescription": "When enabled, user fields from this batch edit are saved first, then all undeleted keys for each user are overwritten from that user's settings." } }, "key": { diff --git a/messages/ja/settings/config.json b/messages/ja/settings/config.json index bc47d2580..feecea153 100644 --- a/messages/ja/settings/config.json +++ b/messages/ja/settings/config.json @@ -13,6 +13,10 @@ "redirected": "リダイレクト後(実際のモデル)" }, "billingModelSourcePlaceholder": "課金モデルソースを選択", + "billingCorrection": { + "title": "課金とレート補正", + "description": "課金モデルソース、Codex Priority の課金参照元、Claude 課金ヘッダー整流をまとめて設定します。" + }, "codexPriorityBillingSource": "Codex Priority 課金参照元", "codexPriorityBillingSourceDesc": "Codex Priority(Fast Mode)の追加課金に使う service_tier を制御します。デフォルトは Requested Service Tier です。Actual Service Tier を選ぶとレスポンス値を優先し、レスポンスに無い場合はリクエスト値へフォールバックします。", "codexPriorityBillingSourceOptions": { diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 8543647fc..fac2cb4c5 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -1622,6 +1622,12 @@ "deleteFailed": "Не удалось удалить пользователя", "userDeleted": "Пользователь удален", "saving": "Сохранение...", + "syncKeys": { + "button": "Синхронизировать с Key", + "loading": "Синхронизация...", + "success": "Синхронизировано ключей: {count}", + "error": "Не удалось синхронизировать ключи" + }, "resetSection": { "title": "Параметры сброса" }, @@ -1680,6 +1686,8 @@ "title": "Подтвердить массовое обновление", "description": "Будут обновлены {users} пользователей и {keys} ключей. Это действие нельзя отменить.", "userFields": "Поля пользователя", + "syncKeys": "Sync to Keys", + "syncKeysDescription": "This will overwrite all undeleted keys for these {users} users from their current user settings.", "keyFields": "Поля ключа", "goBack": "Вернуться и изменить", "update": "Подтвердить обновление", @@ -1688,8 +1696,10 @@ "toast": { "usersUpdated": "Обновлено {count} пользователей", "keysUpdated": "Обновлено {count} ключей", + "keysSynced": "Synced {keys} keys for {users} users", "usersFailed": "Не удалось обновить пользователей: {error}", "keysFailed": "Не удалось обновить ключи: {error}", + "syncFailed": "Sync to keys failed: {error}", "batchFailed": "Массовое обновление не удалось" }, "validation": { @@ -1711,12 +1721,14 @@ "limit5h": "Лимит за 5 часов (USD)", "limitDaily": "Дневной лимит (USD)", "limitWeekly": "Недельный лимит (USD)", - "limitMonthly": "Месячный лимит (USD)" + "limitMonthly": "Месячный лимит (USD)", + "syncKeys": "Sync to Keys" }, "placeholders": { "emptyToClear": "Оставьте пустым, чтобы очистить", "tagsPlaceholder": "Нажмите Enter, чтобы добавить, можно разделять запятыми", - "emptyNoLimit": "Оставьте пустым для безлимита" + "emptyNoLimit": "Оставьте пустым для безлимита", + "syncKeysDescription": "When enabled, user fields from this batch edit are saved first, then all undeleted keys for each user are overwritten from that user's settings." } }, "key": { diff --git a/messages/ru/settings/config.json b/messages/ru/settings/config.json index d6920235a..bbf561581 100644 --- a/messages/ru/settings/config.json +++ b/messages/ru/settings/config.json @@ -13,6 +13,10 @@ "redirected": "После перенаправления (фактическая модель)" }, "billingModelSourcePlaceholder": "Выберите источник модели для тарификации", + "billingCorrection": { + "title": "Коррекция тарификации и ставок", + "description": "Настройте источник модели для тарификации, источник ставки Codex Priority и исправление заголовков тарификации Claude в одном месте." + }, "codexPriorityBillingSource": "Источник тарификации Codex Priority", "codexPriorityBillingSourceDesc": "Определяет, какой service_tier использовать для отдельной тарификации Codex Priority (Fast Mode). По умолчанию используется Requested Service Tier; если выбран Actual Service Tier, сначала берется значение из ответа, а при его отсутствии используется значение из запроса.", "codexPriorityBillingSourceOptions": { diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 347b01908..496800180 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -1640,6 +1640,12 @@ "deleteFailed": "删除用户失败", "userDeleted": "用户已删除", "saving": "保存中...", + "syncKeys": { + "button": "同步到 Key", + "loading": "同步中...", + "success": "已同步到 {count} 把 Key", + "error": "同步 Key 失败" + }, "resetSection": { "title": "重置选项" }, @@ -1698,6 +1704,8 @@ "title": "确认批量更新", "description": "此操作将更新 {users} 个用户和 {keys} 个密钥,操作不可撤销。", "userFields": "用户字段", + "syncKeys": "同步到 Key", + "syncKeysDescription": "会按当前用户配置覆盖这 {users} 个用户的全部未删除 Key。", "keyFields": "密钥字段", "goBack": "返回修改", "update": "确认更新", @@ -1706,8 +1714,10 @@ "toast": { "usersUpdated": "已更新 {count} 个用户", "keysUpdated": "已更新 {count} 个密钥", + "keysSynced": "已同步 {users} 个用户的 {keys} 把 Key", "usersFailed": "用户更新失败:{error}", "keysFailed": "密钥更新失败:{error}", + "syncFailed": "同步到 Key 失败:{error}", "batchFailed": "批量更新失败" }, "validation": { @@ -1729,12 +1739,14 @@ "limit5h": "5h 限额 (USD)", "limitDaily": "每日限额 (USD)", "limitWeekly": "周限额 (USD)", - "limitMonthly": "月限额 (USD)" + "limitMonthly": "月限额 (USD)", + "syncKeys": "同步到 Key" }, "placeholders": { "emptyToClear": "留空表示清空", "tagsPlaceholder": "输入后回车添加,支持逗号分隔", - "emptyNoLimit": "留空表示不限额" + "emptyNoLimit": "留空表示不限额", + "syncKeysDescription": "启用后,会先保存本批量编辑里的用户字段,再按用户配置同步该用户全部未删除 Key。" } }, "key": { diff --git a/messages/zh-CN/settings/config.json b/messages/zh-CN/settings/config.json index 8b95c5bdf..ae97e0685 100644 --- a/messages/zh-CN/settings/config.json +++ b/messages/zh-CN/settings/config.json @@ -32,6 +32,10 @@ "original": "重定向前(原始模型)", "redirected": "重定向后(实际模型)" }, + "billingCorrection": { + "title": "计费与费率矫正", + "description": "集中配置模型计费来源、Codex Priority 费率口径,以及 Claude 计费标头整流开关。" + }, "codexPriorityBillingSource": "Codex Priority 计费来源", "codexPriorityBillingSourcePlaceholder": "选择 Codex Priority 计费来源", "codexPriorityBillingSourceDesc": "控制 Codex Priority(Fast Mode)单独计费使用哪个 service_tier。默认按 Requested Service Tier 计费;若选择 Actual Service Tier,则优先使用响应返回值,响应未返回时回退到请求值。", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index b2a41eebb..ace5acb7b 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -1625,6 +1625,12 @@ "deleteFailed": "刪除使用者失敗", "userDeleted": "使用者已刪除", "saving": "儲存中...", + "syncKeys": { + "button": "同步到 Key", + "loading": "同步中...", + "success": "已同步到 {count} 把 Key", + "error": "同步 Key 失敗" + }, "resetSection": { "title": "重置選項" }, @@ -1683,6 +1689,8 @@ "title": "確認批量更新", "description": "此操作將更新 {users} 位使用者和 {keys} 個金鑰,操作不可撤銷。", "userFields": "使用者欄位", + "syncKeys": "同步到 Key", + "syncKeysDescription": "會按目前使用者設定覆蓋這 {users} 位使用者的全部未刪除 Key。", "keyFields": "金鑰欄位", "goBack": "返回編輯", "update": "確認更新", @@ -1691,8 +1699,10 @@ "toast": { "usersUpdated": "已更新 {count} 位使用者", "keysUpdated": "已更新 {count} 個金鑰", + "keysSynced": "已同步 {users} 位使用者的 {keys} 個 Key", "usersFailed": "使用者更新失敗:{error}", "keysFailed": "金鑰更新失敗:{error}", + "syncFailed": "同步到 Key 失敗:{error}", "batchFailed": "批量更新失敗" }, "validation": { @@ -1714,12 +1724,14 @@ "limit5h": "5h 限額(USD)", "limitDaily": "每日限額(USD)", "limitWeekly": "週限額(USD)", - "limitMonthly": "月限額(USD)" + "limitMonthly": "月限額(USD)", + "syncKeys": "同步到 Key" }, "placeholders": { "emptyToClear": "留空表示清除", "tagsPlaceholder": "輸入後按 Enter 新增,支援逗號分隔", - "emptyNoLimit": "留空表示不限額" + "emptyNoLimit": "留空表示不限額", + "syncKeysDescription": "啟用後,會先儲存本次批量編輯中的使用者欄位,再按使用者設定同步該使用者全部未刪除 Key。" } }, "key": { diff --git a/messages/zh-TW/settings/config.json b/messages/zh-TW/settings/config.json index cfeb43fd2..85d3f3c8d 100644 --- a/messages/zh-TW/settings/config.json +++ b/messages/zh-TW/settings/config.json @@ -13,6 +13,10 @@ "redirected": "重新導向後(實際模型)" }, "billingModelSourcePlaceholder": "選擇計費模型來源", + "billingCorrection": { + "title": "計費與費率矯正", + "description": "集中設定模型計費來源、Codex Priority 費率口徑,以及 Claude 計費標頭整流開關。" + }, "codexPriorityBillingSource": "Codex Priority 計費來源", "codexPriorityBillingSourceDesc": "控制 Codex Priority(Fast Mode)單獨計費使用哪個 service_tier。預設按 Requested Service Tier 計費;若選擇 Actual Service Tier,則優先使用回應返回值,回應未返回時回退到請求值。", "codexPriorityBillingSourceOptions": { diff --git a/src/actions/users.ts b/src/actions/users.ts index 821823b91..0c49567f3 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -1,11 +1,16 @@ "use server"; import { randomBytes } from "node:crypto"; -import { and, eq, inArray, isNull } from "drizzle-orm"; +import { and, asc, eq, inArray, isNull } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { getLocale, getTranslations } from "next-intl/server"; import { db } from "@/drizzle/db"; -import { messageRequest, usageLedger, users as usersTable } from "@/drizzle/schema"; +import { + keys as keysTable, + messageRequest, + usageLedger, + users as usersTable, +} from "@/drizzle/schema"; import { emitActionAudit } from "@/lib/audit/emit"; import { getSession } from "@/lib/auth"; import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; @@ -13,9 +18,14 @@ import { logger } from "@/lib/logger"; import { getUnauthorizedFields } from "@/lib/permissions/user-field-permissions"; import { clipStartByResetAt, resolveUser5hCostResetAt } from "@/lib/rate-limit/cost-reset-utils"; import { getRedisClient } from "@/lib/redis"; -import { invalidateCachedUser } from "@/lib/security/api-key-auth-cache"; +import { invalidateCachedKey, invalidateCachedUser } from "@/lib/security/api-key-auth-cache"; +import { + buildFirstSyncedKeyConfig, + buildSyncedKeyConfigs, + type UserKeySyncSummary, +} from "@/lib/users/user-key-sync"; import { parseDateInputAsTimezone } from "@/lib/utils/date-input"; -import { ERROR_CODES } from "@/lib/utils/error-messages"; +import { ERROR_CODES, zodErrorToCode } from "@/lib/utils/error-messages"; import { normalizeProviderGroup, parseProviderGroups } from "@/lib/utils/provider-group"; import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { maskKey } from "@/lib/utils/validation"; @@ -69,6 +79,35 @@ export interface GetUsersBatchParams { sortOrder?: "asc" | "desc"; } +function warnUserCacheInvalidationFailure(context: string, userId: number, error: unknown) { + logger.warn(`[UserAction] Failed to invalidate user cache after ${context}`, { + userId, + error: error instanceof Error ? error.message : String(error), + }); +} + +function warnKeyCacheInvalidationFailure(context: string, key: string, error: unknown) { + logger.warn(`[UserAction] Failed to invalidate key cache after ${context}`, { + key: maskKey(key), + error: error instanceof Error ? error.message : String(error), + }); +} + +function invalidateUserKeySyncCaches(context: string, userIds: number[], keyStrings: string[]) { + void Promise.all([ + ...userIds.map((userId) => + invalidateCachedUser(userId).catch((error) => + warnUserCacheInvalidationFailure(context, userId, error) + ) + ), + ...keyStrings.map((key) => + invalidateCachedKey(key).catch((error) => + warnKeyCacheInvalidationFailure(context, key, error) + ) + ), + ]); +} + const USER_LIST_DEFAULT_LIMIT = 50; const USER_LIST_MAX_LIMIT = 200; const SEARCH_USERS_MAX_LIMIT = 5000; @@ -257,6 +296,95 @@ class BatchUpdateError extends Error { } } +type EditableUserData = { + name?: string; + note?: string; + providerGroup?: string | null; + tags?: string[]; + rpm?: number | null; + dailyQuota?: number | null; + limit5hUsd?: number | null; + limit5hResetMode?: "fixed" | "rolling"; + limitWeeklyUsd?: number | null; + limitMonthlyUsd?: number | null; + limitTotalUsd?: number | null; + limitConcurrentSessions?: number | null; + dailyResetMode?: "fixed" | "rolling"; + dailyResetTime?: string; + isEnabled?: boolean; + expiresAt?: Date | null; + allowedClients?: string[]; + blockedClients?: string[]; + allowedModels?: string[]; +}; + +export interface SyncUserConfigToKeysResult { + updatedUserId: number; + updatedKeyIds: number[]; + keyCount: number; + summary: UserKeySyncSummary; +} + +export interface BatchSyncUserConfigToKeysResult { + requestedCount: number; + updatedUserCount: number; + updatedKeyCount: number; + users: SyncUserConfigToKeysResult[]; +} + +function buildUserDbUpdates( + validatedData: EditableUserData, + options: { forceUpdatedAt?: boolean } = {} +): Record { + const dbUpdates: Record = {}; + if (options.forceUpdatedAt) dbUpdates.updatedAt = new Date(); + if (validatedData.name !== undefined) dbUpdates.name = validatedData.name; + if (validatedData.note !== undefined) dbUpdates.description = validatedData.note; + if (validatedData.providerGroup !== undefined) { + dbUpdates.providerGroup = normalizeProviderGroup(validatedData.providerGroup); + } + if (validatedData.tags !== undefined) dbUpdates.tags = validatedData.tags; + if (validatedData.rpm !== undefined) dbUpdates.rpmLimit = validatedData.rpm; + if (validatedData.dailyQuota !== undefined) { + dbUpdates.dailyLimitUsd = + validatedData.dailyQuota === null ? null : validatedData.dailyQuota.toString(); + } + if (validatedData.limit5hUsd !== undefined) { + dbUpdates.limit5hUsd = + validatedData.limit5hUsd === null ? null : validatedData.limit5hUsd.toString(); + } + if (validatedData.limit5hResetMode !== undefined) + dbUpdates.limit5hResetMode = validatedData.limit5hResetMode; + if (validatedData.limitWeeklyUsd !== undefined) { + dbUpdates.limitWeeklyUsd = + validatedData.limitWeeklyUsd === null ? null : validatedData.limitWeeklyUsd.toString(); + } + if (validatedData.limitMonthlyUsd !== undefined) { + dbUpdates.limitMonthlyUsd = + validatedData.limitMonthlyUsd === null ? null : validatedData.limitMonthlyUsd.toString(); + } + if (validatedData.limitTotalUsd !== undefined) { + dbUpdates.limitTotalUsd = + validatedData.limitTotalUsd === null ? null : validatedData.limitTotalUsd.toString(); + } + if (validatedData.limitConcurrentSessions !== undefined) { + dbUpdates.limitConcurrentSessions = validatedData.limitConcurrentSessions; + } + if (validatedData.dailyResetMode !== undefined) + dbUpdates.dailyResetMode = validatedData.dailyResetMode; + if (validatedData.dailyResetTime !== undefined) + dbUpdates.dailyResetTime = validatedData.dailyResetTime; + if (validatedData.isEnabled !== undefined) dbUpdates.isEnabled = validatedData.isEnabled; + if (validatedData.expiresAt !== undefined) dbUpdates.expiresAt = validatedData.expiresAt; + if (validatedData.allowedClients !== undefined) + dbUpdates.allowedClients = validatedData.allowedClients; + if (validatedData.blockedClients !== undefined) + dbUpdates.blockedClients = validatedData.blockedClients; + if (validatedData.allowedModels !== undefined) + dbUpdates.allowedModels = validatedData.allowedModels; + return dbUpdates; +} + /** * 验证过期时间的公共函数 * @param expiresAt - 过期时间 @@ -1176,6 +1304,201 @@ function emitUserCreateAudit(user: UserCreateAuditSnapshot): void { }); } +export async function batchSyncUserConfigToKeys(params: { + userIds: number[]; + updates?: BatchUpdateUsersParams["updates"]; +}): Promise> { + try { + const tError = await getTranslations("errors"); + + const session = await getSession(); + if (!session) { + return { + ok: false, + error: tError("UNAUTHORIZED"), + errorCode: ERROR_CODES.UNAUTHORIZED, + }; + } + if (session.user.role !== "admin") { + return { + ok: false, + error: tError("PERMISSION_DENIED"), + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const MAX_BATCH_SIZE = 500; + const requestedIds = Array.from(new Set(params.userIds)).filter((id) => Number.isInteger(id)); + if (requestedIds.length === 0) { + return { ok: false, error: tError("REQUIRED_FIELD"), errorCode: ERROR_CODES.REQUIRED_FIELD }; + } + if (requestedIds.length > MAX_BATCH_SIZE) { + return { + ok: false, + error: tError("BATCH_SIZE_EXCEEDED", { max: MAX_BATCH_SIZE }), + errorCode: ERROR_CODES.INVALID_FORMAT, + }; + } + + const updatesSchema = UpdateUserSchema.pick({ + note: true, + tags: true, + rpm: true, + dailyQuota: true, + limit5hUsd: true, + limit5hResetMode: true, + limitWeeklyUsd: true, + limitMonthlyUsd: true, + }); + const validationResult = updatesSchema.safeParse(params.updates ?? {}); + if (!validationResult.success) { + return { + ok: false, + error: formatZodError(validationResult.error), + errorCode: ERROR_CODES.INVALID_FORMAT, + }; + } + + const updates = validationResult.data; + const hasAnyUpdate = Object.values(updates).some((value) => value !== undefined); + let result: BatchSyncUserConfigToKeysResult | null = null; + let keyStringsForCache: string[] = []; + + await db.transaction(async (tx) => { + if (hasAnyUpdate) { + await tx + .update(usersTable) + .set(buildUserDbUpdates(updates, { forceUpdatedAt: true })) + .where(and(inArray(usersTable.id, requestedIds), isNull(usersTable.deletedAt))); + } + + const userRows = await tx + .select({ + id: usersTable.id, + dailyQuota: usersTable.dailyLimitUsd, + providerGroup: usersTable.providerGroup, + limit5hUsd: usersTable.limit5hUsd, + limit5hResetMode: usersTable.limit5hResetMode, + limitWeeklyUsd: usersTable.limitWeeklyUsd, + limitMonthlyUsd: usersTable.limitMonthlyUsd, + limitTotalUsd: usersTable.limitTotalUsd, + limitConcurrentSessions: usersTable.limitConcurrentSessions, + dailyResetMode: usersTable.dailyResetMode, + dailyResetTime: usersTable.dailyResetTime, + }) + .from(usersTable) + .where(and(inArray(usersTable.id, requestedIds), isNull(usersTable.deletedAt))) + .orderBy(asc(usersTable.id)); + + const existingSet = new Set(userRows.map((row) => row.id)); + const missingIds = requestedIds.filter((id) => !existingSet.has(id)); + if (missingIds.length > 0) { + throw new BatchUpdateError( + `${tError("USER_NOT_FOUND")}: ${missingIds.join(", ")}`, + ERROR_CODES.NOT_FOUND + ); + } + + const keyRows = await tx + .select({ + id: keysTable.id, + key: keysTable.key, + userId: keysTable.userId, + }) + .from(keysTable) + .where(and(inArray(keysTable.userId, requestedIds), isNull(keysTable.deletedAt))) + .orderBy(asc(keysTable.userId), asc(keysTable.createdAt), asc(keysTable.id)); + + const keysByUserId = new Map(); + for (const keyRow of keyRows) { + const rows = keysByUserId.get(keyRow.userId) ?? []; + rows.push(keyRow); + keysByUserId.set(keyRow.userId, rows); + } + + const userResults: SyncUserConfigToKeysResult[] = []; + + for (const userRow of userRows) { + const userKeys = keysByUserId.get(userRow.id) ?? []; + const { configs, summary } = buildSyncedKeyConfigs( + { + dailyQuota: userRow.dailyQuota, + limit5hUsd: userRow.limit5hUsd, + limit5hResetMode: userRow.limit5hResetMode, + limitWeeklyUsd: userRow.limitWeeklyUsd, + limitMonthlyUsd: userRow.limitMonthlyUsd, + limitTotalUsd: userRow.limitTotalUsd, + limitConcurrentSessions: userRow.limitConcurrentSessions, + providerGroup: userRow.providerGroup, + dailyResetMode: userRow.dailyResetMode, + dailyResetTime: userRow.dailyResetTime, + }, + userKeys.length + ); + + for (const [index, keyRow] of userKeys.entries()) { + const config = configs[index]; + await tx + .update(keysTable) + .set({ + updatedAt: new Date(), + limit5hUsd: config.limit5hUsd === null ? null : config.limit5hUsd.toString(), + limit5hResetMode: config.limit5hResetMode, + 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))); + } + + userResults.push({ + updatedUserId: userRow.id, + updatedKeyIds: userKeys.map((keyRow) => keyRow.id), + keyCount: userKeys.length, + summary, + }); + } + + keyStringsForCache = keyRows.map((keyRow) => keyRow.key); + result = { + requestedCount: requestedIds.length, + updatedUserCount: userRows.length, + updatedKeyCount: keyRows.length, + users: userResults, + }; + }); + + invalidateUserKeySyncCaches("batch user-key sync", requestedIds, keyStringsForCache); + + revalidatePath("/dashboard"); + return { + ok: true, + data: result ?? { + requestedCount: requestedIds.length, + updatedUserCount: 0, + updatedKeyCount: 0, + users: [], + }, + }; + } catch (error) { + if (error instanceof BatchUpdateError) { + return { ok: false, error: error.message, errorCode: error.errorCode }; + } + + logger.error("Failed to batch sync user config to keys:", error); + const tError = await getTranslations("errors"); + const message = error instanceof Error ? error.message : tError("UPDATE_USER_FAILED"); + return { ok: false, error: message, errorCode: ERROR_CODES.UPDATE_FAILED }; + } +} + // 添加用户 export async function addUser(data: { name: string; @@ -1264,18 +1587,16 @@ export async function addUser(data: { if (!validationResult.success) { const issue = validationResult.error.issues[0]; - const { code, params } = await import("@/lib/utils/error-messages").then((m) => - m.zodErrorToCode(issue.code, { - minimum: "minimum" in issue ? issue.minimum : undefined, - maximum: "maximum" in issue ? issue.maximum : undefined, - type: "expected" in issue ? issue.expected : undefined, - received: "received" in issue ? issue.received : undefined, - validation: "validation" in issue ? issue.validation : undefined, - path: issue.path, - message: "message" in issue ? issue.message : undefined, - params: "params" in issue ? issue.params : undefined, - }) - ); + const { code, params } = zodErrorToCode(issue.code, { + minimum: "minimum" in issue ? issue.minimum : undefined, + maximum: "maximum" in issue ? issue.maximum : undefined, + type: "expected" in issue ? issue.expected : undefined, + received: "received" in issue ? issue.received : undefined, + validation: "validation" in issue ? issue.validation : undefined, + path: issue.path, + message: "message" in issue ? issue.message : undefined, + params: "params" in issue ? issue.params : undefined, + }); // For custom errors with nested field keys, translate them let translatedParams = params; @@ -1325,13 +1646,34 @@ export async function addUser(data: { // 为新用户创建默认密钥 const generatedKey = `sk-${randomBytes(16).toString("hex")}`; + const defaultKeyConfig = buildFirstSyncedKeyConfig({ + dailyQuota: newUser.dailyQuota ?? null, + limit5hUsd: newUser.limit5hUsd ?? null, + limit5hResetMode: newUser.limit5hResetMode, + limitWeeklyUsd: newUser.limitWeeklyUsd ?? null, + limitMonthlyUsd: newUser.limitMonthlyUsd ?? null, + limitTotalUsd: newUser.limitTotalUsd ?? null, + limitConcurrentSessions: newUser.limitConcurrentSessions ?? null, + providerGroup: newUser.providerGroup ?? providerGroup, + dailyResetMode: newUser.dailyResetMode, + dailyResetTime: newUser.dailyResetTime, + }); const newKey = await createKey({ user_id: newUser.id, name: "default", key: generatedKey, is_enabled: true, expires_at: undefined, - provider_group: providerGroup, + limit_5h_usd: defaultKeyConfig.limit5hUsd, + limit_5h_reset_mode: defaultKeyConfig.limit5hResetMode, + limit_daily_usd: defaultKeyConfig.limitDailyUsd, + daily_reset_mode: defaultKeyConfig.dailyResetMode, + daily_reset_time: defaultKeyConfig.dailyResetTime, + limit_weekly_usd: defaultKeyConfig.limitWeeklyUsd, + limit_monthly_usd: defaultKeyConfig.limitMonthlyUsd, + limit_total_usd: defaultKeyConfig.limitTotalUsd, + limit_concurrent_sessions: defaultKeyConfig.limitConcurrentSessions, + provider_group: defaultKeyConfig.providerGroup, }); revalidatePath("/dashboard"); @@ -1425,6 +1767,8 @@ export async function createUserOnly(data: { limitMonthlyUsd: number | null; limitTotalUsd: number | null; limitConcurrentSessions: number | null; + dailyResetMode: "fixed" | "rolling"; + dailyResetTime: string; }; }> > { @@ -1546,6 +1890,8 @@ export async function createUserOnly(data: { limitMonthlyUsd: newUser.limitMonthlyUsd ?? null, limitTotalUsd: newUser.limitTotalUsd ?? null, limitConcurrentSessions: newUser.limitConcurrentSessions ?? null, + dailyResetMode: newUser.dailyResetMode, + dailyResetTime: newUser.dailyResetTime, }, }, }; @@ -1759,6 +2105,186 @@ export async function editUser( } } +export async function syncUserConfigToKeys( + userId: number, + data: EditableUserData +): Promise> { + try { + const tError = await getTranslations("errors"); + + const session = await getSession(); + if (!session) { + return { + ok: false, + error: tError("UNAUTHORIZED"), + errorCode: ERROR_CODES.UNAUTHORIZED, + }; + } + + if (session.user.role !== "admin") { + return { + ok: false, + error: tError("PERMISSION_DENIED"), + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const validationResult = UpdateUserSchema.safeParse(data); + if (!validationResult.success) { + const issue = validationResult.error.issues[0]; + const { code, params } = await import("@/lib/utils/error-messages").then((m) => + m.zodErrorToCode(issue.code, { + minimum: "minimum" in issue ? issue.minimum : undefined, + maximum: "maximum" in issue ? issue.maximum : undefined, + type: "expected" in issue ? issue.expected : undefined, + received: "received" in issue ? issue.received : undefined, + validation: "validation" in issue ? issue.validation : undefined, + path: issue.path, + message: "message" in issue ? issue.message : undefined, + params: "params" in issue ? issue.params : undefined, + }) + ); + + let translatedParams = params; + if (issue.code === "custom" && params?.field && typeof params.field === "string") { + try { + translatedParams = { + ...params, + field: tError(params.field as string), + }; + } catch { + // Keep original if translation fails + } + } + + return { + ok: false, + error: formatZodError(validationResult.error), + errorCode: code, + errorParams: translatedParams, + }; + } + + const validatedData = validationResult.data; + const unauthorizedFields = getUnauthorizedFields(validatedData, session.user.role); + if (unauthorizedFields.length > 0) { + return { + ok: false, + error: `${tError("PERMISSION_DENIED")}: ${unauthorizedFields.join(", ")}`, + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + let result: SyncUserConfigToKeysResult | null = null; + let keyStringsForCache: string[] = []; + + await db.transaction(async (tx) => { + const [updatedUser] = await tx + .update(usersTable) + .set(buildUserDbUpdates(validatedData, { forceUpdatedAt: true })) + .where(and(eq(usersTable.id, userId), isNull(usersTable.deletedAt))) + .returning({ + id: usersTable.id, + dailyQuota: usersTable.dailyLimitUsd, + providerGroup: usersTable.providerGroup, + limit5hUsd: usersTable.limit5hUsd, + limit5hResetMode: usersTable.limit5hResetMode, + limitWeeklyUsd: usersTable.limitWeeklyUsd, + limitMonthlyUsd: usersTable.limitMonthlyUsd, + limitTotalUsd: usersTable.limitTotalUsd, + limitConcurrentSessions: usersTable.limitConcurrentSessions, + dailyResetMode: usersTable.dailyResetMode, + dailyResetTime: usersTable.dailyResetTime, + }); + + if (!updatedUser) { + throw new BatchUpdateError(tError("USER_NOT_FOUND"), ERROR_CODES.NOT_FOUND); + } + + const keyRows = await tx + .select({ + id: keysTable.id, + key: keysTable.key, + }) + .from(keysTable) + .where(and(eq(keysTable.userId, userId), isNull(keysTable.deletedAt))) + .orderBy(asc(keysTable.createdAt), asc(keysTable.id)); + + const { configs, summary } = buildSyncedKeyConfigs( + { + dailyQuota: updatedUser.dailyQuota, + limit5hUsd: updatedUser.limit5hUsd, + limit5hResetMode: updatedUser.limit5hResetMode, + limitWeeklyUsd: updatedUser.limitWeeklyUsd, + limitMonthlyUsd: updatedUser.limitMonthlyUsd, + limitTotalUsd: updatedUser.limitTotalUsd, + limitConcurrentSessions: updatedUser.limitConcurrentSessions, + providerGroup: updatedUser.providerGroup, + dailyResetMode: updatedUser.dailyResetMode, + dailyResetTime: updatedUser.dailyResetTime, + }, + keyRows.length + ); + + for (const [index, keyRow] of keyRows.entries()) { + const config = configs[index]; + await tx + .update(keysTable) + .set({ + updatedAt: new Date(), + limit5hUsd: config.limit5hUsd === null ? null : config.limit5hUsd.toString(), + limit5hResetMode: config.limit5hResetMode, + 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))); + } + + keyStringsForCache = keyRows.map((keyRow) => keyRow.key); + result = { + updatedUserId: updatedUser.id, + updatedKeyIds: keyRows.map((keyRow) => keyRow.id), + keyCount: keyRows.length, + summary, + }; + }); + + invalidateUserKeySyncCaches("user-key sync", [userId], keyStringsForCache); + + revalidatePath("/dashboard"); + return { + ok: true, + data: result ?? { + updatedUserId: userId, + updatedKeyIds: [], + keyCount: 0, + summary: buildSyncedKeyConfigs({}, 0).summary, + }, + }; + } catch (error) { + if (error instanceof BatchUpdateError) { + return { ok: false, error: error.message, errorCode: error.errorCode }; + } + + logger.error("Failed to sync user config to keys:", error); + const tError = await getTranslations("errors"); + const message = error instanceof Error ? error.message : tError("UPDATE_USER_FAILED"); + return { + ok: false, + error: message, + errorCode: ERROR_CODES.UPDATE_FAILED, + }; + } +} + // 删除用户 // Ledger rows intentionally survive user deletion (billing audit trail) export async function removeUser(userId: number): Promise { diff --git a/src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx b/src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx index d466a2dde..6956e00d8 100644 --- a/src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx @@ -6,7 +6,11 @@ import { useTranslations } from "next-intl"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { type BatchUpdateKeysParams, batchUpdateKeys } from "@/actions/keys"; -import { type BatchUpdateUsersParams, batchUpdateUsers } from "@/actions/users"; +import { + type BatchUpdateUsersParams, + batchSyncUserConfigToKeys, + batchUpdateUsers, +} from "@/actions/users"; import { AlertDialog, AlertDialogAction, @@ -51,6 +55,7 @@ type UserFieldLabels = { limitDaily: string; limitWeekly: string; limitMonthly: string; + syncKeys: string; }; type KeyFieldLabels = { @@ -78,6 +83,7 @@ const INITIAL_USER_STATE: BatchUserSectionState = { limitWeeklyUsd: "", limitMonthlyUsdEnabled: false, limitMonthlyUsd: "", + syncKeysEnabled: false, }; const INITIAL_KEY_STATE: BatchKeySectionState = { @@ -203,6 +209,7 @@ type PendingBatchUpdate = { keyUpdates?: BatchUpdateKeysParams["updates"]; enabledUserFields: string[]; enabledKeyFields: string[]; + syncUsersToKeys: boolean; }; function BatchEditDialogInner({ @@ -242,6 +249,7 @@ function BatchEditDialogInner({ limitDaily: t("user.fields.limitDaily"), limitWeekly: t("user.fields.limitWeekly"), limitMonthly: t("user.fields.limitMonthly"), + syncKeys: t("user.fields.syncKeys"), }), [t] ); @@ -291,8 +299,9 @@ function BatchEditDialogInner({ const willUpdateUsers = selectedUsersCount > 0 && enabledUserFields.length > 0; const willUpdateKeys = selectedKeysCount > 0 && enabledKeyFields.length > 0; + const willSyncUsersToKeys = selectedUsersCount > 0 && userState.syncKeysEnabled; - if (!willUpdateUsers && !willUpdateKeys) { + if (!willUpdateUsers && !willUpdateKeys && !willSyncUsersToKeys) { toast.error(t("dialog.noFieldEnabled")); return; } @@ -304,6 +313,7 @@ function BatchEditDialogInner({ keyUpdates: willUpdateKeys ? keyUpdates : undefined, enabledUserFields, enabledKeyFields, + syncUsersToKeys: willSyncUsersToKeys, }); setConfirmOpen(true); } catch (error) { @@ -317,47 +327,73 @@ function BatchEditDialogInner({ setIsSubmitting(true); try { - const tasks: Array> = []; - - if (pendingUpdate.userUpdates && pendingUpdate.userIds.length > 0) { - tasks.push( - batchUpdateUsers({ userIds: pendingUpdate.userIds, updates: pendingUpdate.userUpdates }) - .then((result) => ({ kind: "users" as const, result })) - .catch((error) => ({ kind: "users" as const, result: { ok: false, error } })) - ); + const results: Array<{ kind: "users" | "keys" | "sync"; result: any }> = []; + + if (pendingUpdate.syncUsersToKeys && pendingUpdate.userIds.length > 0) { + try { + const result = await batchSyncUserConfigToKeys({ + userIds: pendingUpdate.userIds, + updates: pendingUpdate.userUpdates, + }); + results.push({ kind: "sync", result }); + } catch (error) { + results.push({ kind: "sync", result: { ok: false, error } }); + } + } else if (pendingUpdate.userUpdates && pendingUpdate.userIds.length > 0) { + try { + const result = await batchUpdateUsers({ + userIds: pendingUpdate.userIds, + updates: pendingUpdate.userUpdates, + }); + results.push({ kind: "users", result }); + } catch (error) { + results.push({ kind: "users", result: { ok: false, error } }); + } } if (pendingUpdate.keyUpdates && pendingUpdate.keyIds.length > 0) { - tasks.push( - batchUpdateKeys({ keyIds: pendingUpdate.keyIds, updates: pendingUpdate.keyUpdates }) - .then((result) => ({ kind: "keys" as const, result })) - .catch((error) => ({ kind: "keys" as const, result: { ok: false, error } })) - ); + try { + const result = await batchUpdateKeys({ + keyIds: pendingUpdate.keyIds, + updates: pendingUpdate.keyUpdates, + }); + results.push({ kind: "keys", result }); + } catch (error) { + results.push({ kind: "keys", result: { ok: false, error } }); + } } - if (tasks.length === 0) { + if (results.length === 0) { toast.error(t("dialog.noUpdate")); return; } - const results = await Promise.all(tasks); let anySuccess = false; let anyFailed = false; for (const { kind, result } of results) { if (result?.ok) { anySuccess = true; - const updatedCount = - typeof result.data?.updatedCount === "number" - ? result.data.updatedCount - : kind === "users" - ? pendingUpdate.userIds.length - : pendingUpdate.keyIds.length; - toast.success( - kind === "users" - ? t("toast.usersUpdated", { count: updatedCount }) - : t("toast.keysUpdated", { count: updatedCount }) - ); + if (kind === "sync") { + toast.success( + t("toast.keysSynced", { + users: result.data?.updatedUserCount ?? pendingUpdate.userIds.length, + keys: result.data?.updatedKeyCount ?? 0, + }) + ); + } else { + const updatedCount = + typeof result.data?.updatedCount === "number" + ? result.data.updatedCount + : kind === "users" + ? pendingUpdate.userIds.length + : pendingUpdate.keyIds.length; + toast.success( + kind === "users" + ? t("toast.usersUpdated", { count: updatedCount }) + : t("toast.keysUpdated", { count: updatedCount }) + ); + } } else { anyFailed = true; const errorMessage = @@ -366,11 +402,15 @@ function BatchEditDialogInner({ : result?.error instanceof Error ? result.error.message : t("toast.batchFailed"); - toast.error( - kind === "users" - ? t("toast.usersFailed", { error: errorMessage }) - : t("toast.keysFailed", { error: errorMessage }) - ); + if (kind === "sync") { + toast.error(t("toast.syncFailed", { error: errorMessage })); + } else { + toast.error( + kind === "users" + ? t("toast.usersFailed", { error: errorMessage }) + : t("toast.keysFailed", { error: errorMessage }) + ); + } } } @@ -398,7 +438,8 @@ function BatchEditDialogInner({ if (!pendingUpdate) return null; const willUpdateUsers = Boolean(pendingUpdate.userUpdates && pendingUpdate.userIds.length > 0); const willUpdateKeys = Boolean(pendingUpdate.keyUpdates && pendingUpdate.keyIds.length > 0); - const usersCount = willUpdateUsers ? pendingUpdate.userIds.length : 0; + const willSyncUsersToKeys = pendingUpdate.syncUsersToKeys; + const usersCount = willUpdateUsers || willSyncUsersToKeys ? pendingUpdate.userIds.length : 0; const keysCount = willUpdateKeys ? pendingUpdate.keyIds.length : 0; return ( @@ -414,6 +455,14 @@ function BatchEditDialogInner({ ) : null} + {willSyncUsersToKeys ? ( +
+
{t("confirm.syncKeys")}
+
+ {t("confirm.syncKeysDescription", { users: pendingUpdate.userIds.length })} +
+
+ ) : null} {willUpdateKeys ? (
{t("confirm.keyFields")}
@@ -458,6 +507,7 @@ function BatchEditDialogInner({ emptyToClear: t("user.placeholders.emptyToClear"), tagsPlaceholder: t("user.placeholders.tagsPlaceholder"), emptyNoLimit: t("user.placeholders.emptyNoLimit"), + syncKeysDescription: t("user.placeholders.syncKeysDescription"), }, }} /> diff --git a/src/app/[locale]/dashboard/_components/user/batch-edit/batch-user-section.tsx b/src/app/[locale]/dashboard/_components/user/batch-edit/batch-user-section.tsx index 5a3d5acbf..a791f9bac 100644 --- a/src/app/[locale]/dashboard/_components/user/batch-edit/batch-user-section.tsx +++ b/src/app/[locale]/dashboard/_components/user/batch-edit/batch-user-section.tsx @@ -20,6 +20,7 @@ export interface BatchUserSectionState { limitWeeklyUsd: string; limitMonthlyUsdEnabled: boolean; limitMonthlyUsd: string; + syncKeysEnabled: boolean; } export interface BatchUserSectionProps { @@ -38,11 +39,13 @@ export interface BatchUserSectionProps { limitDaily: string; limitWeekly: string; limitMonthly: string; + syncKeys: string; }; placeholders: { emptyToClear: string; tagsPlaceholder: string; emptyNoLimit: string; + syncKeysDescription: string; }; }; } @@ -171,6 +174,17 @@ export function BatchUserSection({ placeholder={translations.placeholders.emptyNoLimit} /> + + onChange({ syncKeysEnabled: enabled })} + enableFieldAria={translations.enableFieldAria} + > +

+ {translations.placeholders.syncKeysDescription} +

+
); diff --git a/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx b/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx index 256925de3..bb6671675 100644 --- a/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx @@ -23,6 +23,7 @@ import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; import { useZodForm } from "@/lib/hooks/use-zod-form"; +import { buildFirstSyncedKeyConfig } from "@/lib/users/user-key-sync"; import { KeyFormSchema, UpdateUserSchema } from "@/lib/validation/schemas"; import { KeyEditSection } from "./forms/key-edit-section"; import { UserEditSection } from "./forms/user-edit-section"; @@ -171,6 +172,19 @@ function CreateUserDialogInner({ onOpenChange, onSuccess }: CreateUserDialogProp } const newUserId = userRes.data.user.id; + const savedUser = userRes.data.user; + const firstKeyConfig = buildFirstSyncedKeyConfig({ + dailyQuota: savedUser.dailyQuota ?? null, + limit5hUsd: savedUser.limit5hUsd ?? null, + limit5hResetMode: savedUser.limit5hResetMode, + limitWeeklyUsd: savedUser.limitWeeklyUsd ?? null, + limitMonthlyUsd: savedUser.limitMonthlyUsd ?? null, + limitTotalUsd: savedUser.limitTotalUsd ?? null, + limitConcurrentSessions: savedUser.limitConcurrentSessions ?? null, + providerGroup: savedUser.providerGroup ?? PROVIDER_GROUP.DEFAULT, + dailyResetMode: savedUser.dailyResetMode, + dailyResetTime: savedUser.dailyResetTime, + }); // Create the first key const keyRes = await addKey({ @@ -179,17 +193,17 @@ function CreateUserDialogInner({ onOpenChange, onSuccess }: CreateUserDialogProp // 重要:清除到期时间时用空字符串表达,避免 undefined 在 Server Action 序列化时被丢弃 expiresAt: data.key.expiresAt ?? "", canLoginWebUi: data.key.canLoginWebUi, - providerGroup: normalizeProviderGroup(data.key.providerGroup), + providerGroup: firstKeyConfig.providerGroup, cacheTtlPreference: data.key.cacheTtlPreference, - limit5hUsd: data.key.limit5hUsd, - limit5hResetMode: data.key.limit5hResetMode, - limitDailyUsd: data.key.limitDailyUsd, - dailyResetMode: data.key.dailyResetMode, - dailyResetTime: data.key.dailyResetTime, - limitWeeklyUsd: data.key.limitWeeklyUsd, - limitMonthlyUsd: data.key.limitMonthlyUsd, - limitTotalUsd: data.key.limitTotalUsd, - limitConcurrentSessions: data.key.limitConcurrentSessions, + limit5hUsd: firstKeyConfig.limit5hUsd, + limit5hResetMode: firstKeyConfig.limit5hResetMode, + limitDailyUsd: firstKeyConfig.limitDailyUsd, + dailyResetMode: firstKeyConfig.dailyResetMode, + dailyResetTime: firstKeyConfig.dailyResetTime, + limitWeeklyUsd: firstKeyConfig.limitWeeklyUsd, + limitMonthlyUsd: firstKeyConfig.limitMonthlyUsd, + limitTotalUsd: firstKeyConfig.limitTotalUsd, + limitConcurrentSessions: firstKeyConfig.limitConcurrentSessions, }); if (!keyRes.ok) { @@ -410,7 +424,9 @@ function CreateUserDialogInner({ onOpenChange, onSuccess }: CreateUserDialogProp }} isAdmin={true} showLimitRules={false} - showExpireTime={false} + showExpireTime={true} + showProviderGroup={false} + showEnableStatus={false} onChange={handleKeyChange} translations={keyEditTranslations} /> diff --git a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx index 90e36ca2c..313a6d09d 100644 --- a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx @@ -12,6 +12,7 @@ import { removeUser, resetUserAllStatistics, resetUserLimitsOnly, + syncUserConfigToKeys, toggleUserEnabled, } from "@/actions/users"; import { @@ -100,6 +101,7 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr const [reset5hDialogOpen, setReset5hDialogOpen] = useState(false); const [isResettingLimits, setIsResettingLimits] = useState(false); const [resetLimitsDialogOpen, setResetLimitsDialogOpen] = useState(false); + const [isSyncingKeys, setIsSyncingKeys] = useState(false); // Always show providerGroup field in edit mode const userEditTranslations = useUserTranslations({ showProviderGroup: true }); @@ -303,6 +305,50 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr const canReset5h = (user.limit5hUsd ?? null) !== null && (user.limit5hUsd ?? 0) > 0; const reset5hMode = user.limit5hResetMode ?? "rolling"; + const handleSyncKeys = async () => { + setIsSyncingKeys(true); + try { + const data = form.values || defaultValues; + const res = await syncUserConfigToKeys(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 (!res.ok) { + toast.error(res.error || t("editDialog.syncKeys.error")); + return; + } + + toast.success(t("editDialog.syncKeys.success", { count: res.data.keyCount })); + onSuccess?.(); + queryClient.invalidateQueries({ queryKey: ["users"] }); + queryClient.invalidateQueries({ queryKey: ["userKeyGroups"] }); + queryClient.invalidateQueries({ queryKey: ["userTags"] }); + router.refresh(); + } catch (error) { + console.error("[EditUserDialog] sync keys failed", error); + toast.error(t("editDialog.syncKeys.error")); + } finally { + setIsSyncingKeys(false); + } + }; + return (
@@ -544,15 +590,24 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr {errorMessage &&
{errorMessage}
} + - diff --git a/src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx b/src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx index beaed6e92..5216a3d4c 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx @@ -54,6 +54,10 @@ export interface KeyEditSectionProps { showLimitRules?: boolean; /** 是否显示到期时间区域,默认为 true */ showExpireTime?: boolean; + /** 是否显示供应商分组,默认为 true */ + showProviderGroup?: boolean; + /** 是否显示启用状态,默认为 true */ + showEnableStatus?: boolean; onChange: { (field: string, value: any): void; (batch: Record): void; @@ -131,6 +135,8 @@ export function KeyEditSection({ userProviderGroup, showLimitRules = true, showExpireTime = true, + showProviderGroup = true, + showEnableStatus = true, onChange, scrollRef, translations, @@ -338,36 +344,38 @@ export function KeyEditSection({ value={keyData.name} onChange={(val) => onChange("name", val)} /> -
-
- -

- {translations.fields.enableStatus?.description || "Disabled keys cannot be used"} -

+ {showEnableStatus && ( +
+
+ +

+ {translations.fields.enableStatus?.description || "Disabled keys cannot be used"} +

+
+ + +
+ onChange("isEnabled", checked)} + /> +
+
+ {isLastEnabledKey && ( + +

+ {translations.fields.enableStatus?.cannotDisableTooltip || + "Cannot disable the last enabled key"} +

+
+ )} +
- - -
- onChange("isEnabled", checked)} - /> -
-
- {isLastEnabledKey && ( - -

- {translations.fields.enableStatus?.cannotDisableTooltip || - "Cannot disable the last enabled key"} -

-
- )} -
-
+ )} {/* 到期时间区域 - 仅在 showExpireTime 为 true 时显示 */} @@ -458,7 +466,7 @@ export function KeyEditSection({ />
- {isAdmin ? ( + {showProviderGroup && isAdmin ? ( onChange("providerGroup", val)} @@ -468,7 +476,7 @@ export function KeyEditSection({ placeholder: translations.fields.providerGroup.placeholder, }} /> - ) : userGroups.length > 0 ? ( + ) : showProviderGroup && userGroups.length > 0 ? (
{keyData.id > 0 ? ( // 编辑模式:只读显示 @@ -508,7 +516,7 @@ export function KeyEditSection({ /> )}
- ) : keyGroupOptions.length > 0 ? ( + ) : showProviderGroup && keyGroupOptions.length > 0 ? (
@@ -527,11 +535,11 @@ export function KeyEditSection({ {translations.fields.providerGroup.editHint || "已有密钥的分组不可修改"}

- ) : ( + ) : showProviderGroup ? (
{translations.fields.providerGroup.noGroupHint || "您没有分组限制,可以访问所有供应商"}
- )} + ) : null}
diff --git a/src/app/[locale]/settings/config/_components/system-settings-form.tsx b/src/app/[locale]/settings/config/_components/system-settings-form.tsx index c60180fff..6030cdf5b 100644 --- a/src/app/[locale]/settings/config/_components/system-settings-form.tsx +++ b/src/app/[locale]/settings/config/_components/system-settings-form.tsx @@ -377,52 +377,89 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)

{t("currencyDisplayDesc")}

- {/* Billing Model Source Select */} -
- - -

{t("billingModelSourceDesc")}

-
+
+
+
+ +
+
+

{t("billingCorrection.title")}

+

+ {t("billingCorrection.description")} +

+
+
-
- - -

{t("codexPriorityBillingSourceDesc")}

+
+ + +

{t("billingModelSourceDesc")}

+
+ +
+ + +

{t("codexPriorityBillingSourceDesc")}

+
+ +
+
+

+ {t("enableBillingHeaderRectifier")} +

+

+ {t("enableBillingHeaderRectifierDesc")} +

+
+ setEnableBillingHeaderRectifier(checked)} + disabled={isPending} + /> +
{/* Timezone Select */} @@ -645,29 +682,6 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) />
- {/* Enable Billing Header Rectifier */} -
-
-
- -
-
-

- {t("enableBillingHeaderRectifier")} -

-

- {t("enableBillingHeaderRectifierDesc")} -

-
-
- setEnableBillingHeaderRectifier(checked)} - disabled={isPending} - /> -
- {/* Enable Response Input Rectifier */}
diff --git a/src/app/[locale]/settings/config/page.tsx b/src/app/[locale]/settings/config/page.tsx index bbe54df4b..fd8db3548 100644 --- a/src/app/[locale]/settings/config/page.tsx +++ b/src/app/[locale]/settings/config/page.tsx @@ -1,6 +1,8 @@ import { getTranslations } from "next-intl/server"; +import Link from "next/link"; import { Suspense } from "react"; import { Section } from "@/components/section"; +import { Button } from "@/components/ui/button"; import { getSystemSettings } from "@/repository/system-config"; import { SettingsPageHeader } from "../_components/settings-page-header"; import { AutoCleanupForm } from "./_components/auto-cleanup-form"; @@ -18,6 +20,11 @@ export default async function SettingsConfigPage() { title={t("config.title")} description={t("config.description")} icon="settings" + actions={ + + } /> }> diff --git a/src/lib/users/user-key-sync.ts b/src/lib/users/user-key-sync.ts new file mode 100644 index 000000000..b5eee81d1 --- /dev/null +++ b/src/lib/users/user-key-sync.ts @@ -0,0 +1,192 @@ +import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; +import { normalizeProviderGroup } from "@/lib/utils/provider-group"; + +export type UserKeySyncAmountField = + | "limit5hUsd" + | "limitDailyUsd" + | "limitWeeklyUsd" + | "limitMonthlyUsd" + | "limitTotalUsd"; + +export interface UserKeySyncSource { + dailyQuota?: number | string | null; + limit5hUsd?: number | string | null; + limitWeeklyUsd?: number | string | null; + limitMonthlyUsd?: number | string | null; + limitTotalUsd?: number | string | null; + limitConcurrentSessions?: number | string | null; + providerGroup?: string | null; + limit5hResetMode?: "fixed" | "rolling" | null; + dailyResetMode?: "fixed" | "rolling" | null; + dailyResetTime?: string | null; +} + +export interface SyncedKeyConfig { + limit5hUsd: number | null; + limitDailyUsd: number | null; + limitWeeklyUsd: number | null; + limitMonthlyUsd: number | null; + limitTotalUsd: number | null; + limitConcurrentSessions: number; + providerGroup: string; + limit5hResetMode: "fixed" | "rolling"; + dailyResetMode: "fixed" | "rolling"; + dailyResetTime: string; +} + +export interface UserKeySyncFieldSummary { + values: Array; + discarded?: number; +} + +export interface UserKeySyncSummary { + limit5hUsd: UserKeySyncFieldSummary; + limitDailyUsd: UserKeySyncFieldSummary; + limitWeeklyUsd: UserKeySyncFieldSummary; + limitMonthlyUsd: UserKeySyncFieldSummary; + limitTotalUsd: UserKeySyncFieldSummary; + limitConcurrentSessions: UserKeySyncFieldSummary; + providerGroup: string; + limit5hResetMode: "fixed" | "rolling"; + dailyResetMode: "fixed" | "rolling"; + dailyResetTime: string; +} + +const AMOUNT_FIELDS = [ + "limit5hUsd", + "limitDailyUsd", + "limitWeeklyUsd", + "limitMonthlyUsd", + "limitTotalUsd", +] as const; + +function toFiniteNumber(value: number | string | null | undefined): number | null { + if (value === null || value === undefined || value === "") return null; + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function centsToUsd(cents: number): number { + return Number((cents / 100).toFixed(2)); +} + +function distributeAmount( + value: number | string | null | undefined, + keyCount: number +): { values: Array; discarded: number } { + if (keyCount <= 0) return { values: [], discarded: 0 }; + + 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, + }; +} + +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, + }; +} + +export function buildSyncedKeyConfigs( + source: UserKeySyncSource, + keyCount: number +): { configs: SyncedKeyConfig[]; summary: UserKeySyncSummary } { + const normalizedKeyCount = Math.max(0, Math.floor(keyCount)); + const providerGroup = normalizeProviderGroup(source.providerGroup ?? PROVIDER_GROUP.DEFAULT); + const limit5hResetMode: "fixed" | "rolling" = + source.limit5hResetMode === "fixed" ? "fixed" : "rolling"; + const dailyResetMode: "fixed" | "rolling" = + source.dailyResetMode === "rolling" ? "rolling" : "fixed"; + const dailyResetTime = source.dailyResetTime || "00:00"; + const amountInputByField: Record = { + limit5hUsd: source.limit5hUsd, + limitDailyUsd: source.dailyQuota, + limitWeeklyUsd: source.limitWeeklyUsd, + limitMonthlyUsd: source.limitMonthlyUsd, + limitTotalUsd: source.limitTotalUsd, + }; + const amountByField = Object.fromEntries( + AMOUNT_FIELDS.map((field) => [ + field, + distributeAmount(amountInputByField[field], normalizedKeyCount), + ]) + ) as Record; discarded: number }>; + const concurrent = distributeConcurrentSessions( + source.limitConcurrentSessions, + normalizedKeyCount + ); + + 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, + limitMonthlyUsd: amountByField.limitMonthlyUsd.values[index] ?? null, + limitTotalUsd: amountByField.limitTotalUsd.values[index] ?? null, + limitConcurrentSessions: concurrent.values[index] ?? 0, + providerGroup, + limit5hResetMode, + dailyResetMode, + dailyResetTime, + })); + + return { + configs, + summary: { + limit5hUsd: amountByField.limit5hUsd, + limitDailyUsd: amountByField.limitDailyUsd, + limitWeeklyUsd: amountByField.limitWeeklyUsd, + limitMonthlyUsd: amountByField.limitMonthlyUsd, + limitTotalUsd: amountByField.limitTotalUsd, + limitConcurrentSessions: concurrent, + providerGroup, + limit5hResetMode, + dailyResetMode, + dailyResetTime, + }, + }; +} + +export function buildFirstSyncedKeyConfig(source: UserKeySyncSource): SyncedKeyConfig { + return buildSyncedKeyConfigs(source, 1).configs[0]; +} diff --git a/tests/unit/actions/users-key-sync.test.ts b/tests/unit/actions/users-key-sync.test.ts new file mode 100644 index 000000000..8730208d8 --- /dev/null +++ b/tests/unit/actions/users-key-sync.test.ts @@ -0,0 +1,325 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { keys as keysTable, users as usersTable } from "@/drizzle/schema"; +import { ERROR_CODES } from "@/lib/utils/error-messages"; + +const getSessionMock = vi.fn(); +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})); + +vi.mock("next-intl/server", () => ({ + getLocale: vi.fn(async () => "en"), + getTranslations: vi.fn(async () => (key: string) => key), +})); + +const createUserMock = vi.fn(); +const updateUserMock = vi.fn(); +vi.mock("@/repository/user", () => ({ + createUser: createUserMock, + deleteUser: vi.fn(), + findUserById: vi.fn(), + findUserListBatch: vi.fn(), + getAllUserProviderGroups: vi.fn(), + getAllUserTags: vi.fn(), + resetUserCostResetAt: vi.fn(), + searchUsersForFilter: vi.fn(), + updateUser: updateUserMock, +})); + +const createKeyMock = vi.fn(); +vi.mock("@/repository/key", () => ({ + createKey: createKeyMock, + findKeyList: vi.fn(async () => []), + findKeyListBatch: vi.fn(async () => new Map()), + findKeysStatisticsBatchFromKeys: vi.fn(async () => new Map()), + findKeyUsageTodayBatch: vi.fn(async () => new Map()), +})); + +const invalidateCachedKeyMock = vi.fn(); +const invalidateCachedUserMock = vi.fn(); +vi.mock("@/lib/security/api-key-auth-cache", () => ({ + invalidateCachedKey: invalidateCachedKeyMock, + invalidateCachedUser: invalidateCachedUserMock, +})); + +const userReturningMock = vi.fn(); +const batchUserRowsOrderByMock = vi.fn(); +const keyRowsOrderByMock = vi.fn(); +const userUpdatePayloads: Array> = []; +const keyUpdatePayloads: Array> = []; +const dbTransactionMock = vi.fn(async (fn: (tx: any) => Promise) => { + const tx = { + update: vi.fn((table) => ({ + set: vi.fn((payload) => { + if (table === usersTable) { + userUpdatePayloads.push(payload); + return { + where: vi.fn(() => ({ + returning: userReturningMock, + })), + }; + } + + keyUpdatePayloads.push(payload); + return { + where: vi.fn(async () => undefined), + }; + }), + })), + select: vi.fn(() => ({ + from: vi.fn((table) => { + if (table === usersTable) { + return { + where: vi.fn(() => ({ + orderBy: batchUserRowsOrderByMock, + })), + }; + } + + expect(table).toBe(keysTable); + return { + where: vi.fn(() => ({ + orderBy: keyRowsOrderByMock, + })), + }; + }), + })), + }; + await fn(tx); +}); + +vi.mock("@/drizzle/db", () => ({ + db: { + transaction: dbTransactionMock, + }, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, +})); + +describe("users key sync actions", () => { + beforeEach(() => { + vi.clearAllMocks(); + userUpdatePayloads.length = 0; + keyUpdatePayloads.length = 0; + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + invalidateCachedKeyMock.mockResolvedValue(undefined); + invalidateCachedUserMock.mockResolvedValue(undefined); + }); + + test("addUser creates the default key with user limits", async () => { + createUserMock.mockResolvedValue({ + id: 10, + name: "alice", + description: "", + role: "user", + isEnabled: true, + expiresAt: null, + rpm: 0, + dailyQuota: 100, + providerGroup: "fast", + tags: [], + limit5hUsd: 10, + limitWeeklyUsd: 200, + limitMonthlyUsd: 500, + limitTotalUsd: 1000, + limitConcurrentSessions: 3, + limit5hResetMode: "fixed", + dailyResetMode: "rolling", + dailyResetTime: "18:30", + allowedModels: [], + }); + createKeyMock.mockResolvedValue({ id: 20, name: "default" }); + + const { addUser } = await import("@/actions/users"); + const result = await addUser({ + name: "alice", + providerGroup: "fast", + dailyQuota: 100, + limit5hUsd: 10, + limitWeeklyUsd: 200, + limitMonthlyUsd: 500, + limitTotalUsd: 1000, + limitConcurrentSessions: 3, + dailyResetMode: "rolling", + dailyResetTime: "18:30", + }); + + expect(result.ok).toBe(true); + expect(createKeyMock).toHaveBeenCalledWith( + expect.objectContaining({ + user_id: 10, + name: "default", + limit_5h_usd: 10, + limit_5h_reset_mode: "fixed", + limit_daily_usd: 100, + limit_weekly_usd: 200, + limit_monthly_usd: 500, + limit_total_usd: 1000, + limit_concurrent_sessions: 3, + provider_group: "fast", + daily_reset_mode: "rolling", + daily_reset_time: "18:30", + }) + ); + }); + + test("syncUserConfigToKeys saves user and updates all undeleted keys by created order", async () => { + userReturningMock.mockResolvedValue([ + { + id: 10, + dailyQuota: "100.00", + providerGroup: "fast", + limit5hUsd: "0.02", + limit5hResetMode: "fixed", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + limitConcurrentSessions: 2, + dailyResetMode: "rolling", + dailyResetTime: "18:30", + }, + ]); + keyRowsOrderByMock.mockResolvedValue([ + { id: 1, key: "sk-1" }, + { id: 2, key: "sk-2" }, + { id: 3, key: "sk-3" }, + ]); + + const { syncUserConfigToKeys } = await import("@/actions/users"); + const result = await syncUserConfigToKeys(10, { + name: "alice", + providerGroup: "fast", + dailyQuota: 100, + limit5hUsd: 0.02, + limitConcurrentSessions: 2, + limit5hResetMode: "fixed", + dailyResetMode: "rolling", + dailyResetTime: "18:30", + }); + + expect(result.ok).toBe(true); + expect(keyUpdatePayloads).toHaveLength(3); + expect(keyUpdatePayloads.map((payload) => payload.limitDailyUsd)).toEqual([ + "33.33", + "33.33", + "33.33", + ]); + expect(keyUpdatePayloads.map((payload) => payload.limit5hUsd)).toEqual(["0.01", "0.01", null]); + expect(keyUpdatePayloads.every((payload) => payload.limit5hResetMode === "fixed")).toBe(true); + expect(keyUpdatePayloads.map((payload) => payload.limitConcurrentSessions)).toEqual([1, 1, 0]); + expect(keyUpdatePayloads.every((payload) => payload.providerGroup === "fast")).toBe(true); + expect(keyUpdatePayloads.every((payload) => payload.dailyResetMode === "rolling")).toBe(true); + expect(keyUpdatePayloads.every((payload) => payload.dailyResetTime === "18:30")).toBe(true); + expect(keyUpdatePayloads.some((payload) => "canLoginWebUi" in payload)).toBe(false); + expect(keyUpdatePayloads.some((payload) => "name" in payload)).toBe(false); + expect(keyUpdatePayloads.some((payload) => "expiresAt" in payload)).toBe(false); + expect(keyUpdatePayloads.some((payload) => "cacheTtlPreference" in payload)).toBe(false); + expect(invalidateCachedUserMock).toHaveBeenCalledWith(10); + expect(invalidateCachedKeyMock).toHaveBeenCalledTimes(3); + }); + + test("batchSyncUserConfigToKeys saves batch user fields and syncs keys per user", async () => { + batchUserRowsOrderByMock.mockResolvedValue([ + { + id: 10, + dailyQuota: "100.00", + providerGroup: "fast", + limit5hUsd: "0.02", + limit5hResetMode: "fixed", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + limitConcurrentSessions: 2, + dailyResetMode: "rolling", + dailyResetTime: "18:30", + }, + { + id: 11, + dailyQuota: null, + providerGroup: "slow", + limit5hUsd: null, + limit5hResetMode: "rolling", + limitWeeklyUsd: "9.00", + limitMonthlyUsd: null, + limitTotalUsd: null, + limitConcurrentSessions: 0, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + }, + ]); + keyRowsOrderByMock.mockResolvedValue([ + { id: 1, key: "sk-1", userId: 10 }, + { id: 2, key: "sk-2", userId: 10 }, + { id: 3, key: "sk-3", userId: 10 }, + { id: 4, key: "sk-4", userId: 11 }, + ]); + + const { batchSyncUserConfigToKeys } = await import("@/actions/users"); + const result = await batchSyncUserConfigToKeys({ + userIds: [10, 11], + updates: { + note: "batch-note", + dailyQuota: 100, + limit5hUsd: 0.02, + limit5hResetMode: "fixed", + }, + }); + + expect(result.ok).toBe(true); + expect(userUpdatePayloads[0]).toMatchObject({ + description: "batch-note", + dailyLimitUsd: "100", + limit5hUsd: "0.02", + limit5hResetMode: "fixed", + }); + expect(keyUpdatePayloads).toHaveLength(4); + expect(keyUpdatePayloads.slice(0, 3).map((payload) => payload.limitDailyUsd)).toEqual([ + "33.33", + "33.33", + "33.33", + ]); + expect(keyUpdatePayloads.slice(0, 3).map((payload) => payload.limit5hUsd)).toEqual([ + "0.01", + "0.01", + null, + ]); + expect(keyUpdatePayloads.slice(0, 3).map((payload) => payload.limitConcurrentSessions)).toEqual( + [1, 1, 0] + ); + expect(keyUpdatePayloads[3]).toMatchObject({ + limitDailyUsd: null, + limitWeeklyUsd: "9", + limitConcurrentSessions: 0, + providerGroup: "slow", + limit5hResetMode: "rolling", + dailyResetMode: "fixed", + dailyResetTime: "00:00", + }); + expect(invalidateCachedUserMock).toHaveBeenCalledWith(10); + expect(invalidateCachedUserMock).toHaveBeenCalledWith(11); + expect(invalidateCachedKeyMock).toHaveBeenCalledTimes(4); + }); + + test("syncUserConfigToKeys rejects non-admin users", async () => { + getSessionMock.mockResolvedValue({ user: { id: 2, role: "user" } }); + + const { syncUserConfigToKeys } = await import("@/actions/users"); + const result = await syncUserConfigToKeys(10, { name: "alice" }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errorCode).toBe(ERROR_CODES.PERMISSION_DENIED); + } + expect(dbTransactionMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/actions/users-reset-all-statistics.test.ts b/tests/unit/actions/users-reset-all-statistics.test.ts index a6280bf0a..531776b02 100644 --- a/tests/unit/actions/users-reset-all-statistics.test.ts +++ b/tests/unit/actions/users-reset-all-statistics.test.ts @@ -73,6 +73,7 @@ vi.mock("@/lib/logger", () => ({ // Mock invalidateCachedUser (called directly after transaction) const invalidateCachedUserMock = vi.fn(); vi.mock("@/lib/security/api-key-auth-cache", () => ({ + invalidateCachedKey: vi.fn(), invalidateCachedUser: invalidateCachedUserMock, })); diff --git a/tests/unit/lib/users/user-key-sync.test.ts b/tests/unit/lib/users/user-key-sync.test.ts new file mode 100644 index 000000000..3c6f28eb1 --- /dev/null +++ b/tests/unit/lib/users/user-key-sync.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "vitest"; +import { buildFirstSyncedKeyConfig, buildSyncedKeyConfigs } from "@/lib/users/user-key-sync"; + +describe("user key sync allocation", () => { + test("single key gets the same user limits", () => { + const firstKey = buildFirstSyncedKeyConfig({ + dailyQuota: 100, + limit5hUsd: 10, + limitWeeklyUsd: 200, + limitMonthlyUsd: 500, + limitTotalUsd: 1000, + limitConcurrentSessions: 3, + providerGroup: "fast", + limit5hResetMode: "fixed", + dailyResetMode: "rolling", + dailyResetTime: "18:30", + }); + + expect(firstKey).toEqual({ + limit5hUsd: 10, + limitDailyUsd: 100, + limitWeeklyUsd: 200, + limitMonthlyUsd: 500, + limitTotalUsd: 1000, + limitConcurrentSessions: 3, + providerGroup: "fast", + limit5hResetMode: "fixed", + dailyResetMode: "rolling", + dailyResetTime: "18:30", + }); + }); + + test("amount limits are averaged by cents and discard remainder", () => { + const { configs, summary } = buildSyncedKeyConfigs({ dailyQuota: 100 }, 3); + + expect(configs.map((config) => config.limitDailyUsd)).toEqual([33.33, 33.33, 33.33]); + expect(summary.limitDailyUsd.discarded).toBe(0.01); + }); + + test("small amount limits assign cents to early keys and null to the rest", () => { + const { configs } = buildSyncedKeyConfigs({ dailyQuota: 0.02 }, 3); + + expect(configs.map((config) => config.limitDailyUsd)).toEqual([0.01, 0.01, null]); + }); + + test("small concurrent limits assign one session to early keys and zero to the rest", () => { + const { configs } = buildSyncedKeyConfigs({ limitConcurrentSessions: 2 }, 3); + + expect(configs.map((config) => config.limitConcurrentSessions)).toEqual([1, 1, 0]); + }); + + test("null and non-positive values clear key limits", () => { + const { configs } = buildSyncedKeyConfigs( + { + dailyQuota: null, + limit5hUsd: 0, + limitWeeklyUsd: -1, + limitConcurrentSessions: 0, + }, + 2 + ); + + expect(configs.map((config) => config.limitDailyUsd)).toEqual([null, null]); + expect(configs.map((config) => config.limit5hUsd)).toEqual([null, null]); + expect(configs.map((config) => config.limitWeeklyUsd)).toEqual([null, null]); + expect(configs.map((config) => config.limitConcurrentSessions)).toEqual([0, 0]); + }); +}); diff --git a/tests/unit/user-dialogs.test.tsx b/tests/unit/user-dialogs.test.tsx index 199298b87..1301e5eae 100644 --- a/tests/unit/user-dialogs.test.tsx +++ b/tests/unit/user-dialogs.test.tsx @@ -48,6 +48,7 @@ const mockResetUserAllStatistics = vi.fn().mockResolvedValue({ ok: true }); const mockAddKey = vi.fn().mockResolvedValue({ ok: true, data: { key: "sk-test-key" } }); const mockEditKey = vi.fn().mockResolvedValue({ ok: true }); const mockCreateUserOnly = vi.fn().mockResolvedValue({ ok: true, data: { user: { id: 1 } } }); +const mockSyncUserConfigToKeys = vi.fn().mockResolvedValue({ ok: true, data: { keyCount: 2 } }); vi.mock("@/actions/users", () => ({ editUser: (...args: unknown[]) => mockEditUser(...args), @@ -56,6 +57,7 @@ vi.mock("@/actions/users", () => ({ resetUserLimitsOnly: (...args: unknown[]) => mockResetUserLimitsOnly(...args), resetUserAllStatistics: (...args: unknown[]) => mockResetUserAllStatistics(...args), createUserOnly: (...args: unknown[]) => mockCreateUserOnly(...args), + syncUserConfigToKeys: (...args: unknown[]) => mockSyncUserConfigToKeys(...args), })); vi.mock("@/app/[locale]/dashboard/_components/user/actions/reset-user-5h-limit", () => ({ @@ -140,8 +142,21 @@ vi.mock("@/app/[locale]/dashboard/_components/user/forms/user-edit-section", () })); vi.mock("@/app/[locale]/dashboard/_components/user/forms/key-edit-section", () => ({ - KeyEditSection: ({ keyData, onChange, translations: _translations }: any) => ( -
+ KeyEditSection: ({ + keyData, + onChange, + translations: _translations, + showExpireTime, + showProviderGroup, + showEnableStatus, + }: any) => ( +
{ expect(buttonTexts).toContain("Save"); expect(buttonTexts).toContain("Cancel"); + expect(buttonTexts).toContain("Sync to Keys"); unmount(); }); @@ -553,7 +569,11 @@ describe("CreateUserDialog", () => { messages.dashboard.userManagement.createDialog.title ); expect(container.querySelector('[data-testid="user-edit-section"]')).not.toBeNull(); - expect(container.querySelector('[data-testid="key-edit-section"]')).not.toBeNull(); + const keyEditSection = container.querySelector('[data-testid="key-edit-section"]'); + expect(keyEditSection).not.toBeNull(); + expect(keyEditSection?.getAttribute("data-show-expire-time")).toBe("true"); + expect(keyEditSection?.getAttribute("data-show-provider-group")).toBe("false"); + expect(keyEditSection?.getAttribute("data-show-enable-status")).toBe("false"); unmount(); });