From 58a23bbbaa30d60a8893a3e9258c9ceab0858c12 Mon Sep 17 00:00:00 2001 From: mci777 Date: Tue, 14 Apr 2026 14:20:44 +0800 Subject: [PATCH 01/15] feat: allow readonly bearer keys in opaque auth mode 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 --- src/lib/auth.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 0fe1ae039..9fb316437 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -267,6 +267,7 @@ export async function validateAuthToken( options?: { allowReadOnlyAccess?: boolean } ): Promise { const mode = getSessionTokenMode(); + const tokenKind = detectSessionTokenKind(token); if (mode !== "legacy") { try { @@ -293,6 +294,10 @@ export async function validateAuthToken( return validateKey(token, options); } + if (options?.allowReadOnlyAccess && tokenKind === "legacy") { + return validateKey(token, options); + } + // Opaque mode: allow raw ADMIN_TOKEN for backward-compatible programmatic API access. // Safe because admin token is a server-side env secret, not a user-issued DB key. const adminToken = config.auth.adminToken; From 9a37638a634bdb8826da568a799abc308248f7b3 Mon Sep 17 00:00:00 2001 From: mci777 Date: Tue, 14 Apr 2026 14:20:53 +0800 Subject: [PATCH 02/15] feat: expose computed remaining quota fields 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 --- src/actions/my-usage.ts | 144 ++++++++++++++++++++++++ src/app/api/actions/[...route]/route.ts | 34 ++++++ tests/api/my-usage-readonly.test.ts | 55 ++++++++- 3 files changed, 232 insertions(+), 1 deletion(-) diff --git a/src/actions/my-usage.ts b/src/actions/my-usage.ts index a1536f2dc..9fa477667 100644 --- a/src/actions/my-usage.ts +++ b/src/actions/my-usage.ts @@ -208,12 +208,95 @@ export interface MyUsageQuota { keyName: string; keyIsEnabled: boolean; + providerGroup: string | null; + + limit5hUsd: number | null; + used5hUsd: number; + remaining5hUsd: number | null; + + limitDailyUsd: number | null; + usedDailyUsd: number; + remainingDailyUsd: number | null; + + limitWeeklyUsd: number | null; + usedWeeklyUsd: number; + remainingWeeklyUsd: number | null; + + limitMonthlyUsd: number | null; + usedMonthlyUsd: number; + remainingMonthlyUsd: number | null; + + limitTotalUsd: number | null; + usedTotalUsd: number; + remainingTotalUsd: number | null; + + rpmLimit: number | null; + concurrentSessions: number; + concurrentSessionsLimit: number | null; + userAllowedModels: string[]; userAllowedClients: string[]; expiresAt: Date | null; dailyResetMode: "fixed" | "rolling"; dailyResetTime: string; + resetMode: "fixed" | "rolling"; + resetTime: string; + remaining: number | null; + unit: "USD"; +} + +type EffectiveQuotaWindow = { + limit: number | null; + used: number; + remaining: number | null; +}; + +function clampRemaining(limit: number, used: number): number { + return Math.max(limit - used, 0); +} + +function resolveEffectiveQuotaWindow( + candidates: Array<{ limit: number | null | undefined; used: number }> +): EffectiveQuotaWindow { + const boundedCandidates = candidates + .filter((candidate): candidate is { limit: number; used: number } => candidate.limit != null) + .map((candidate) => ({ + limit: candidate.limit, + used: candidate.used, + remaining: clampRemaining(candidate.limit, candidate.used), + })); + + if (boundedCandidates.length === 0) { + return { + limit: null, + used: Math.max(...candidates.map((candidate) => candidate.used), 0), + remaining: null, + }; + } + + const mostRestrictive = boundedCandidates.reduce((current, candidate) => { + if (candidate.remaining < current.remaining) { + return candidate; + } + + if (candidate.remaining === current.remaining && candidate.limit < current.limit) { + return candidate; + } + + return current; + }); + + return mostRestrictive; +} + +function resolveOverallRemaining(values: Array): number | null { + const boundedValues = values.filter((value): value is number => value != null); + if (boundedValues.length === 0) { + return null; + } + + return Math.max(Math.min(...boundedValues), 0); } export interface MyTodayStats { @@ -476,6 +559,37 @@ export async function getMyQuota(): Promise> { const resolvedKeyCurrent5hUsd = keyFixed5hUsd ?? keyCurrent5hUsd; const resolvedUserCurrent5hUsd = userFixed5hUsd ?? userCurrent5hUsd; + const effective5h = resolveEffectiveQuotaWindow([ + { limit: key.limit5hUsd, used: keyCost5h }, + { limit: user.limit5hUsd, used: userCost5h }, + ]); + const effectiveDaily = resolveEffectiveQuotaWindow([ + { limit: key.limitDailyUsd, used: keyCostDaily }, + { limit: user.dailyQuota, used: userCostDaily }, + ]); + const effectiveWeekly = resolveEffectiveQuotaWindow([ + { limit: key.limitWeeklyUsd, used: keyCostWeekly }, + { limit: user.limitWeeklyUsd, used: userCostWeekly }, + ]); + const effectiveMonthly = resolveEffectiveQuotaWindow([ + { limit: key.limitMonthlyUsd, used: keyCostMonthly }, + { limit: user.limitMonthlyUsd, used: userCostMonthly }, + ]); + const effectiveTotal = resolveEffectiveQuotaWindow([ + { limit: key.limitTotalUsd, used: keyTotalCost }, + { limit: user.limitTotalUsd, used: userTotalCost }, + ]); + const overallRemaining = resolveOverallRemaining([ + effective5h.remaining, + effectiveDaily.remaining, + effectiveWeekly.remaining, + effectiveMonthly.remaining, + effectiveTotal.remaining, + ]); + const concurrentSessions = Math.max(keyConcurrent, userKeyConcurrent); + const concurrentSessionsLimit = + effectiveKeyConcurrentLimit > 0 ? effectiveKeyConcurrentLimit : null; + const quota: MyUsageQuota = { keyLimit5hUsd: key.limit5hUsd ?? null, keyLimitDailyUsd: key.limitDailyUsd ?? null, @@ -513,12 +627,42 @@ export async function getMyQuota(): Promise> { keyName: key.name, keyIsEnabled: key.isEnabled ?? true, + providerGroup: key.providerGroup ?? user.providerGroup ?? null, + + limit5hUsd: effective5h.limit, + used5hUsd: effective5h.used, + remaining5hUsd: effective5h.remaining, + + limitDailyUsd: effectiveDaily.limit, + usedDailyUsd: effectiveDaily.used, + remainingDailyUsd: effectiveDaily.remaining, + + limitWeeklyUsd: effectiveWeekly.limit, + usedWeeklyUsd: effectiveWeekly.used, + remainingWeeklyUsd: effectiveWeekly.remaining, + + limitMonthlyUsd: effectiveMonthly.limit, + usedMonthlyUsd: effectiveMonthly.used, + remainingMonthlyUsd: effectiveMonthly.remaining, + + limitTotalUsd: effectiveTotal.limit, + usedTotalUsd: effectiveTotal.used, + remainingTotalUsd: effectiveTotal.remaining, + + rpmLimit: user.rpm ?? null, + concurrentSessions, + concurrentSessionsLimit, + userAllowedModels: user.allowedModels ?? [], userAllowedClients: user.allowedClients ?? [], expiresAt: key.expiresAt ?? null, dailyResetMode: key.dailyResetMode ?? "fixed", dailyResetTime: key.dailyResetTime ?? "00:00", + resetMode: key.dailyResetMode ?? "fixed", + resetTime: key.dailyResetTime ?? "00:00", + remaining: overallRemaining, + unit: "USD", }; return { ok: true, data: quota }; diff --git a/src/app/api/actions/[...route]/route.ts b/src/app/api/actions/[...route]/route.ts index 3cc906a7c..b25e19bbd 100644 --- a/src/app/api/actions/[...route]/route.ts +++ b/src/app/api/actions/[...route]/route.ts @@ -1124,6 +1124,7 @@ const { route: getMyQuotaRoute, handler: getMyQuotaHandler } = createActionRoute userLimitMonthlyUsd: z.number().nullable(), userLimitTotalUsd: z.number().nullable(), userLimitConcurrentSessions: z.number().nullable(), + userRpmLimit: z.number().nullable(), userCurrent5hUsd: z.number(), userCurrentDailyUsd: z.number(), userCurrentWeeklyUsd: z.number(), @@ -1141,9 +1142,42 @@ const { route: getMyQuotaRoute, handler: getMyQuotaHandler } = createActionRoute keyName: z.string(), keyIsEnabled: z.boolean(), + providerGroup: z.string().nullable(), + + limit5hUsd: z.number().nullable(), + used5hUsd: z.number(), + remaining5hUsd: z.number().nullable(), + + limitDailyUsd: z.number().nullable(), + usedDailyUsd: z.number(), + remainingDailyUsd: z.number().nullable(), + + limitWeeklyUsd: z.number().nullable(), + usedWeeklyUsd: z.number(), + remainingWeeklyUsd: z.number().nullable(), + + limitMonthlyUsd: z.number().nullable(), + usedMonthlyUsd: z.number(), + remainingMonthlyUsd: z.number().nullable(), + + limitTotalUsd: z.number().nullable(), + usedTotalUsd: z.number(), + remainingTotalUsd: z.number().nullable(), + + rpmLimit: z.number().nullable(), + concurrentSessions: z.number(), + concurrentSessionsLimit: z.number().nullable(), + + userAllowedModels: z.array(z.string()), + userAllowedClients: z.array(z.string()), + expiresAt: z.string().nullable(), dailyResetMode: z.enum(["fixed", "rolling"]), dailyResetTime: z.string(), + resetMode: z.enum(["fixed", "rolling"]), + resetTime: z.string(), + remaining: z.number().nullable(), + unit: z.literal("USD"), }), description: "获取当前会话的限额与当前使用量(仅返回自己的数据)", summary: "获取我的限额与用量", diff --git a/tests/api/my-usage-readonly.test.ts b/tests/api/my-usage-readonly.test.ts index ab5a6bb7e..577e06bb2 100644 --- a/tests/api/my-usage-readonly.test.ts +++ b/tests/api/my-usage-readonly.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeEach, describe, expect, test, vi } from "vitest"; -import { inArray } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { keys, messageRequest, usageLedger, users } from "@/drizzle/schema"; @@ -349,6 +349,28 @@ describe.skipIf(!process.env.DSN)("my-usage API:只读 Key 自助查询", () = }); createdKeyIds.push(readonlyKey.id); + await db + .update(users) + .set({ + rpmLimit: 60, + dailyLimitUsd: "15", + limit5hUsd: 12, + limitWeeklyUsd: 25, + limitMonthlyUsd: 35, + providerGroup: "default", + }) + .where(eq(users.id, user.id)); + + await db + .update(keys) + .set({ + limit5hUsd: 10, + limitDailyUsd: 20, + limitWeeklyUsd: 30, + limitMonthlyUsd: 40, + }) + .where(eq(keys.id, readonlyKey.id)); + const now = new Date(); const msgId = await createMessage({ userId: user.id, @@ -374,6 +396,37 @@ describe.skipIf(!process.env.DSN)("my-usage API:只读 Key 自助查询", () = expect(stats.json).toMatchObject({ ok: true }); expect((stats.json as any).data.calls).toBe(1); + const quota = await callActionsRoute({ + method: "POST", + pathname: "/api/actions/my-usage/getMyQuota", + headers: { Authorization: currentAuthorization }, + body: {}, + }); + expect(quota.response.status).toBe(200); + expect(quota.json).toMatchObject({ ok: true }); + + const quotaData = (quota.json as { ok: boolean; data: Record }).data; + expect(quotaData.keyName).toBe(readonlyKey.name); + expect(quotaData.userName).toBe(user.name); + expect(quotaData.providerGroup).toBe("default"); + expect(quotaData.keyIsEnabled).toBe(true); + expect(quotaData.userIsEnabled).toBe(true); + expect(quotaData.rpmLimit).toBe(60); + expect(quotaData.unit).toBe("USD"); + expect(quotaData.remaining).toBeTypeOf("number"); + expect(quotaData.remaining5hUsd).toBeTypeOf("number"); + expect(quotaData.remainingDailyUsd).toBeTypeOf("number"); + expect(quotaData.remainingWeeklyUsd).toBeTypeOf("number"); + expect(quotaData.remainingMonthlyUsd).toBeTypeOf("number"); + expect(quotaData.used5hUsd).toBeTypeOf("number"); + expect(quotaData.usedDailyUsd).toBeTypeOf("number"); + expect(quotaData.usedWeeklyUsd).toBeTypeOf("number"); + expect(quotaData.usedMonthlyUsd).toBeTypeOf("number"); + expect(quotaData.limit5hUsd).toBe(10); + expect(quotaData.limitDailyUsd).toBe(15); + expect(quotaData.limitWeeklyUsd).toBe(25); + expect(quotaData.limitMonthlyUsd).toBe(35); + // Issue #687 fix: getUsers 现在也支持 allowReadOnlyAccess const usersApi = await callActionsRoute({ method: "POST", From 2887e4ea0d22d099a00fe42c13b021eca3fd164e Mon Sep 17 00:00:00 2001 From: mci777 Date: Tue, 14 Apr 2026 14:42:30 +0800 Subject: [PATCH 03/15] docs: add direct quota extractor example 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 --- docs/examples/api-key-quota-extractor.js | 146 +++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 docs/examples/api-key-quota-extractor.js diff --git a/docs/examples/api-key-quota-extractor.js b/docs/examples/api-key-quota-extractor.js new file mode 100644 index 000000000..2c1b268d3 --- /dev/null +++ b/docs/examples/api-key-quota-extractor.js @@ -0,0 +1,146 @@ +/** + * Direct Bearer API-key quota lookup adapter for Claude Code Hub. + * + * Usage: + * node docs/examples/api-key-quota-extractor.js \ + * https://cch.fkcodex.com \ + * sk-your-api-key + * + * This script calls: + * POST /api/actions/my-usage/getMyQuota + * with: + * Authorization: Bearer + * + * and normalizes the response into a template-friendly structure. + */ + +function assertOkResponse(payload) { + if (!payload || typeof payload !== "object") { + throw new Error("Quota API returned an empty or non-JSON response"); + } + + if (payload.ok !== true || !payload.data || typeof payload.data !== "object") { + const errorMessage = typeof payload.error === "string" ? payload.error : "Quota API request failed"; + throw new Error(errorMessage); + } + + return payload.data; +} + +function pickNumber(value, fallback = null) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function pickBoolean(value, fallback = false) { + return typeof value === "boolean" ? value : fallback; +} + +function pickString(value, fallback = null) { + return typeof value === "string" && value.length > 0 ? value : fallback; +} + +function normalizeQuotaData(data) { + return { + ok: true, + + keyName: pickString(data.keyName), + userName: pickString(data.userName), + providerGroup: pickString(data.providerGroup), + + keyIsEnabled: pickBoolean(data.keyIsEnabled), + userIsEnabled: pickBoolean(data.userIsEnabled), + + remaining: pickNumber(data.remaining), + unit: pickString(data.unit, "USD"), + + limit5hUsd: pickNumber(data.limit5hUsd), + used5hUsd: pickNumber(data.used5hUsd, 0), + remaining5hUsd: pickNumber(data.remaining5hUsd), + + limitDailyUsd: pickNumber(data.limitDailyUsd), + usedDailyUsd: pickNumber(data.usedDailyUsd, 0), + remainingDailyUsd: pickNumber(data.remainingDailyUsd), + + limitWeeklyUsd: pickNumber(data.limitWeeklyUsd), + usedWeeklyUsd: pickNumber(data.usedWeeklyUsd, 0), + remainingWeeklyUsd: pickNumber(data.remainingWeeklyUsd), + + limitMonthlyUsd: pickNumber(data.limitMonthlyUsd), + usedMonthlyUsd: pickNumber(data.usedMonthlyUsd, 0), + remainingMonthlyUsd: pickNumber(data.remainingMonthlyUsd), + + limitTotalUsd: pickNumber(data.limitTotalUsd), + usedTotalUsd: pickNumber(data.usedTotalUsd, 0), + remainingTotalUsd: pickNumber(data.remainingTotalUsd), + + rpmLimit: pickNumber(data.rpmLimit), + concurrentSessions: pickNumber(data.concurrentSessions, 0), + concurrentSessionsLimit: pickNumber(data.concurrentSessionsLimit), + + expiresAt: pickString(data.expiresAt), + resetMode: pickString(data.resetMode), + resetTime: pickString(data.resetTime), + + // Handy flat aliases for third-party template engines. + isAvailable: + pickBoolean(data.keyIsEnabled) && pickBoolean(data.userIsEnabled) && pickNumber(data.remaining, 0) > 0, + balance: pickNumber(data.remaining), + dailyBalance: pickNumber(data.remainingDailyUsd), + weeklyBalance: pickNumber(data.remainingWeeklyUsd), + monthlyBalance: pickNumber(data.remainingMonthlyUsd), + }; +} + +async function fetchQuota(baseUrl, apiKey) { + const url = new URL("/api/actions/my-usage/getMyQuota", baseUrl).toString(); + + const response = await fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({}), + }); + + const text = await response.text(); + + let payload; + try { + payload = JSON.parse(text); + } catch (error) { + throw new Error(`Quota API did not return JSON (status ${response.status})`); + } + + if (!response.ok) { + const message = typeof payload?.error === "string" ? payload.error : `HTTP ${response.status}`; + throw new Error(message); + } + + return normalizeQuotaData(assertOkResponse(payload)); +} + +async function main() { + const [, , baseUrl, apiKey] = process.argv; + + if (!baseUrl || !apiKey) { + console.error("Usage: node docs/examples/api-key-quota-extractor.js "); + process.exitCode = 1; + return; + } + + const result = await fetchQuota(baseUrl, apiKey); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} + +if (require.main === module) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} + +module.exports = { + fetchQuota, + normalizeQuotaData, +}; From ded84d74eb431b357843c37d2588c979c3186436 Mon Sep 17 00:00:00 2001 From: mci777 Date: Tue, 14 Apr 2026 14:59:05 +0800 Subject: [PATCH 04/15] docs: fix quota extractor template example 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 --- docs/examples/api-key-quota-extractor.js | 202 +++++++---------------- 1 file changed, 60 insertions(+), 142 deletions(-) diff --git a/docs/examples/api-key-quota-extractor.js b/docs/examples/api-key-quota-extractor.js index 2c1b268d3..a892b5dc7 100644 --- a/docs/examples/api-key-quota-extractor.js +++ b/docs/examples/api-key-quota-extractor.js @@ -1,146 +1,64 @@ -/** - * Direct Bearer API-key quota lookup adapter for Claude Code Hub. - * - * Usage: - * node docs/examples/api-key-quota-extractor.js \ - * https://cch.fkcodex.com \ - * sk-your-api-key - * - * This script calls: - * POST /api/actions/my-usage/getMyQuota - * with: - * Authorization: Bearer - * - * and normalizes the response into a template-friendly structure. - */ - -function assertOkResponse(payload) { - if (!payload || typeof payload !== "object") { - throw new Error("Quota API returned an empty or non-JSON response"); - } - - if (payload.ok !== true || !payload.data || typeof payload.data !== "object") { - const errorMessage = typeof payload.error === "string" ? payload.error : "Quota API request failed"; - throw new Error(errorMessage); - } - - return payload.data; -} - -function pickNumber(value, fallback = null) { - return typeof value === "number" && Number.isFinite(value) ? value : fallback; -} - -function pickBoolean(value, fallback = false) { - return typeof value === "boolean" ? value : fallback; -} - -function pickString(value, fallback = null) { - return typeof value === "string" && value.length > 0 ? value : fallback; -} - -function normalizeQuotaData(data) { - return { - ok: true, - - keyName: pickString(data.keyName), - userName: pickString(data.userName), - providerGroup: pickString(data.providerGroup), - - keyIsEnabled: pickBoolean(data.keyIsEnabled), - userIsEnabled: pickBoolean(data.userIsEnabled), - - remaining: pickNumber(data.remaining), - unit: pickString(data.unit, "USD"), - - limit5hUsd: pickNumber(data.limit5hUsd), - used5hUsd: pickNumber(data.used5hUsd, 0), - remaining5hUsd: pickNumber(data.remaining5hUsd), - - limitDailyUsd: pickNumber(data.limitDailyUsd), - usedDailyUsd: pickNumber(data.usedDailyUsd, 0), - remainingDailyUsd: pickNumber(data.remainingDailyUsd), - - limitWeeklyUsd: pickNumber(data.limitWeeklyUsd), - usedWeeklyUsd: pickNumber(data.usedWeeklyUsd, 0), - remainingWeeklyUsd: pickNumber(data.remainingWeeklyUsd), - - limitMonthlyUsd: pickNumber(data.limitMonthlyUsd), - usedMonthlyUsd: pickNumber(data.usedMonthlyUsd, 0), - remainingMonthlyUsd: pickNumber(data.remainingMonthlyUsd), - - limitTotalUsd: pickNumber(data.limitTotalUsd), - usedTotalUsd: pickNumber(data.usedTotalUsd, 0), - remainingTotalUsd: pickNumber(data.remainingTotalUsd), - - rpmLimit: pickNumber(data.rpmLimit), - concurrentSessions: pickNumber(data.concurrentSessions, 0), - concurrentSessionsLimit: pickNumber(data.concurrentSessionsLimit), - - expiresAt: pickString(data.expiresAt), - resetMode: pickString(data.resetMode), - resetTime: pickString(data.resetTime), - - // Handy flat aliases for third-party template engines. - isAvailable: - pickBoolean(data.keyIsEnabled) && pickBoolean(data.userIsEnabled) && pickNumber(data.remaining, 0) > 0, - balance: pickNumber(data.remaining), - dailyBalance: pickNumber(data.remainingDailyUsd), - weeklyBalance: pickNumber(data.remainingWeeklyUsd), - monthlyBalance: pickNumber(data.remainingMonthlyUsd), - }; -} - -async function fetchQuota(baseUrl, apiKey) { - const url = new URL("/api/actions/my-usage/getMyQuota", baseUrl).toString(); - - const response = await fetch(url, { +({ + request: { + url: "{{baseUrl}}/api/actions/my-usage/getMyQuota", method: "POST", headers: { - "content-type": "application/json", - authorization: `Bearer ${apiKey}`, + "Authorization": "Bearer {{apiKey}}", + "Content-Type": "application/json", + "User-Agent": "cc-switch/1.0" }, - body: JSON.stringify({}), - }); - - const text = await response.text(); - - let payload; - try { - payload = JSON.parse(text); - } catch (error) { - throw new Error(`Quota API did not return JSON (status ${response.status})`); - } - - if (!response.ok) { - const message = typeof payload?.error === "string" ? payload.error : `HTTP ${response.status}`; - throw new Error(message); - } - - return normalizeQuotaData(assertOkResponse(payload)); -} - -async function main() { - const [, , baseUrl, apiKey] = process.argv; - - if (!baseUrl || !apiKey) { - console.error("Usage: node docs/examples/api-key-quota-extractor.js "); - process.exitCode = 1; - return; + body: {} + }, + + extractor: function(response) { + const data = response && response.ok === true && response.data && typeof response.data === "object" + ? response.data + : {}; + + const toNumber = function(value, fallback) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; + }; + + const toBoolean = function(value, fallback) { + return typeof value === "boolean" ? value : fallback; + }; + + return { + ok: response && response.ok === true, + isValid: toBoolean(data.keyIsEnabled, true) && toBoolean(data.userIsEnabled, true), + + keyName: typeof data.keyName === "string" ? data.keyName : null, + userName: typeof data.userName === "string" ? data.userName : null, + providerGroup: typeof data.providerGroup === "string" ? data.providerGroup : null, + + remaining: toNumber(data.remaining, null), + unit: typeof data.unit === "string" ? data.unit : "USD", + + remaining5hUsd: toNumber(data.remaining5hUsd, null), + remainingDailyUsd: toNumber(data.remainingDailyUsd, null), + remainingWeeklyUsd: toNumber(data.remainingWeeklyUsd, null), + remainingMonthlyUsd: toNumber(data.remainingMonthlyUsd, null), + remainingTotalUsd: toNumber(data.remainingTotalUsd, null), + + limit5hUsd: toNumber(data.limit5hUsd, null), + limitDailyUsd: toNumber(data.limitDailyUsd, null), + limitWeeklyUsd: toNumber(data.limitWeeklyUsd, null), + limitMonthlyUsd: toNumber(data.limitMonthlyUsd, null), + + used5hUsd: toNumber(data.used5hUsd, 0), + usedDailyUsd: toNumber(data.usedDailyUsd, 0), + usedWeeklyUsd: toNumber(data.usedWeeklyUsd, 0), + usedMonthlyUsd: toNumber(data.usedMonthlyUsd, 0), + + rpmLimit: toNumber(data.rpmLimit, null), + expiresAt: typeof data.expiresAt === "string" ? data.expiresAt : null, + resetMode: typeof data.resetMode === "string" ? data.resetMode : null, + resetTime: typeof data.resetTime === "string" ? data.resetTime : null, + + balance: toNumber(data.remaining, null), + dailyBalance: toNumber(data.remainingDailyUsd, null), + weeklyBalance: toNumber(data.remainingWeeklyUsd, null), + monthlyBalance: toNumber(data.remainingMonthlyUsd, null) + }; } - - const result = await fetchQuota(baseUrl, apiKey); - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); -} - -if (require.main === module) { - main().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; - }); -} - -module.exports = { - fetchQuota, - normalizeQuotaData, -}; +}) From 956b178d2b36652770f4565d4d247f4e53a3b4a2 Mon Sep 17 00:00:00 2001 From: mci77777 <457404347@qq.com> Date: Tue, 14 Apr 2026 22:13:18 +0800 Subject: [PATCH 05/15] docs: add quota extractor variants --- .../examples/api-key-quota-extractor-daily.js | 41 +++++++++++++++++++ .../examples/api-key-quota-extractor-total.js | 41 +++++++++++++++++++ .../api-key-quota-extractor-weekly.js | 41 +++++++++++++++++++ docs/examples/api-key-quota-extractor.js | 14 +++++-- 4 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 docs/examples/api-key-quota-extractor-daily.js create mode 100644 docs/examples/api-key-quota-extractor-total.js create mode 100644 docs/examples/api-key-quota-extractor-weekly.js diff --git a/docs/examples/api-key-quota-extractor-daily.js b/docs/examples/api-key-quota-extractor-daily.js new file mode 100644 index 000000000..866752696 --- /dev/null +++ b/docs/examples/api-key-quota-extractor-daily.js @@ -0,0 +1,41 @@ +({ + request: { + url: "{{baseUrl}}/api/actions/my-usage/getMyQuota", + method: "POST", + headers: { + "Authorization": "Bearer {{apiKey}}", + "Content-Type": "application/json", + "User-Agent": "cc-switch/1.0" + }, + body: "{}" + }, + + extractor: function(response) { + const data = response && response.ok === true && response.data && typeof response.data === "object" + ? response.data + : {}; + + const toNumber = function(value, fallback) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; + }; + + const toBoolean = function(value, fallback) { + return typeof value === "boolean" ? value : fallback; + }; + + return { + ok: response && response.ok === true, + isValid: toBoolean(data.keyIsEnabled, true) && toBoolean(data.userIsEnabled, true), + planName: "Daily Quota", + remaining: toNumber(data.remainingDailyUsd, null), + total: toNumber(data.limitDailyUsd, null), + used: toNumber(data.usedDailyUsd, 0), + unit: typeof data.unit === "string" ? data.unit : "USD", + keyName: typeof data.keyName === "string" ? data.keyName : null, + userName: typeof data.userName === "string" ? data.userName : null, + providerGroup: typeof data.providerGroup === "string" ? data.providerGroup : null, + resetMode: typeof data.resetMode === "string" ? data.resetMode : null, + resetTime: typeof data.resetTime === "string" ? data.resetTime : null + }; + } +}) diff --git a/docs/examples/api-key-quota-extractor-total.js b/docs/examples/api-key-quota-extractor-total.js new file mode 100644 index 000000000..3b50f491d --- /dev/null +++ b/docs/examples/api-key-quota-extractor-total.js @@ -0,0 +1,41 @@ +({ + request: { + url: "{{baseUrl}}/api/actions/my-usage/getMyQuota", + method: "POST", + headers: { + "Authorization": "Bearer {{apiKey}}", + "Content-Type": "application/json", + "User-Agent": "cc-switch/1.0" + }, + body: "{}" + }, + + extractor: function(response) { + const data = response && response.ok === true && response.data && typeof response.data === "object" + ? response.data + : {}; + + const toNumber = function(value, fallback) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; + }; + + const toBoolean = function(value, fallback) { + return typeof value === "boolean" ? value : fallback; + }; + + return { + ok: response && response.ok === true, + isValid: toBoolean(data.keyIsEnabled, true) && toBoolean(data.userIsEnabled, true), + planName: "Total Quota", + remaining: toNumber(data.remainingTotalUsd, null), + total: toNumber(data.limitTotalUsd, null), + used: toNumber(data.usedTotalUsd, 0), + unit: typeof data.unit === "string" ? data.unit : "USD", + keyName: typeof data.keyName === "string" ? data.keyName : null, + userName: typeof data.userName === "string" ? data.userName : null, + providerGroup: typeof data.providerGroup === "string" ? data.providerGroup : null, + resetMode: typeof data.resetMode === "string" ? data.resetMode : null, + resetTime: typeof data.resetTime === "string" ? data.resetTime : null + }; + } +}) diff --git a/docs/examples/api-key-quota-extractor-weekly.js b/docs/examples/api-key-quota-extractor-weekly.js new file mode 100644 index 000000000..1678fb353 --- /dev/null +++ b/docs/examples/api-key-quota-extractor-weekly.js @@ -0,0 +1,41 @@ +({ + request: { + url: "{{baseUrl}}/api/actions/my-usage/getMyQuota", + method: "POST", + headers: { + "Authorization": "Bearer {{apiKey}}", + "Content-Type": "application/json", + "User-Agent": "cc-switch/1.0" + }, + body: "{}" + }, + + extractor: function(response) { + const data = response && response.ok === true && response.data && typeof response.data === "object" + ? response.data + : {}; + + const toNumber = function(value, fallback) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; + }; + + const toBoolean = function(value, fallback) { + return typeof value === "boolean" ? value : fallback; + }; + + return { + ok: response && response.ok === true, + isValid: toBoolean(data.keyIsEnabled, true) && toBoolean(data.userIsEnabled, true), + planName: "Weekly Quota", + remaining: toNumber(data.remainingWeeklyUsd, null), + total: toNumber(data.limitWeeklyUsd, null), + used: toNumber(data.usedWeeklyUsd, 0), + unit: typeof data.unit === "string" ? data.unit : "USD", + keyName: typeof data.keyName === "string" ? data.keyName : null, + userName: typeof data.userName === "string" ? data.userName : null, + providerGroup: typeof data.providerGroup === "string" ? data.providerGroup : null, + resetMode: typeof data.resetMode === "string" ? data.resetMode : null, + resetTime: typeof data.resetTime === "string" ? data.resetTime : null + }; + } +}) diff --git a/docs/examples/api-key-quota-extractor.js b/docs/examples/api-key-quota-extractor.js index a892b5dc7..641dc18ba 100644 --- a/docs/examples/api-key-quota-extractor.js +++ b/docs/examples/api-key-quota-extractor.js @@ -7,7 +7,7 @@ "Content-Type": "application/json", "User-Agent": "cc-switch/1.0" }, - body: {} + body: "{}" }, extractor: function(response) { @@ -26,12 +26,15 @@ return { ok: response && response.ok === true, isValid: toBoolean(data.keyIsEnabled, true) && toBoolean(data.userIsEnabled, true), + planName: "Weekly Quota", keyName: typeof data.keyName === "string" ? data.keyName : null, userName: typeof data.userName === "string" ? data.userName : null, providerGroup: typeof data.providerGroup === "string" ? data.providerGroup : null, - remaining: toNumber(data.remaining, null), + remaining: toNumber(data.remainingWeeklyUsd, null), + total: toNumber(data.limitWeeklyUsd, null), + used: toNumber(data.usedWeeklyUsd, 0), unit: typeof data.unit === "string" ? data.unit : "USD", remaining5hUsd: toNumber(data.remaining5hUsd, null), @@ -54,8 +57,13 @@ expiresAt: typeof data.expiresAt === "string" ? data.expiresAt : null, resetMode: typeof data.resetMode === "string" ? data.resetMode : null, resetTime: typeof data.resetTime === "string" ? data.resetTime : null, + extra: [ + "Overall remaining: " + (toNumber(data.remaining, null) ?? "unlimited"), + "Daily remaining: " + (toNumber(data.remainingDailyUsd, null) ?? "unlimited"), + "Monthly remaining: " + (toNumber(data.remainingMonthlyUsd, null) ?? "unlimited") + ].join(" | "), - balance: toNumber(data.remaining, null), + balance: toNumber(data.remainingWeeklyUsd, null), dailyBalance: toNumber(data.remainingDailyUsd, null), weeklyBalance: toNumber(data.remainingWeeklyUsd, null), monthlyBalance: toNumber(data.remainingMonthlyUsd, null) From d9c07c8212ca88227d0930feb93534e8c4488e0e Mon Sep 17 00:00:00 2001 From: mci77777 <457404347@qq.com> Date: Fri, 17 Apr 2026 18:34:18 +0800 Subject: [PATCH 06/15] feat: add temporary key groups and rollout safeguards --- Dockerfile | 4 + dev/docker-compose.yaml | 10 +- docker-compose.yaml | 4 +- messages/en/dashboard.json | 66 +- messages/ja/dashboard.json | 50 +- messages/ru/dashboard.json | 50 +- messages/zh-CN/dashboard.json | 66 +- messages/zh-TW/dashboard.json | 50 +- scripts/deploy.ps1 | 4 +- scripts/deploy.sh | 4 +- scripts/server-zero-downtime-rollout.sh | 660 ++++++++++++++++++ src/actions/keys.ts | 404 +++++++++++ src/actions/users.ts | 3 + .../_components/dashboard-header.tsx | 1 + .../user/temporary-key-batch-dialog.tsx | 317 +++++++++ .../_components/user/user-key-table-row.tsx | 485 ++++++++++--- .../user/user-management-table.tsx | 6 + .../dashboard/users/users-page-client.tsx | 28 +- src/drizzle/schema.ts | 6 + src/lib/availability/availability-service.ts | 2 + src/lib/availability/types.ts | 2 + src/repository/_shared/transformers.ts | 1 + src/repository/key.ts | 105 ++- src/types/key.ts | 9 + src/types/user.ts | 2 + tests/unit/actions/temporary-keys.test.ts | 388 ++++++++++ tests/unit/proxy/auth-guard.test.ts | 161 +++++ 27 files changed, 2797 insertions(+), 91 deletions(-) create mode 100644 scripts/server-zero-downtime-rollout.sh create mode 100644 src/app/[locale]/dashboard/_components/user/temporary-key-batch-dialog.tsx create mode 100644 tests/unit/actions/temporary-keys.test.ts create mode 100644 tests/unit/proxy/auth-guard.test.ts diff --git a/Dockerfile b/Dockerfile index 8576fbc54..263413ff8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,10 @@ ENV NODE_ENV=production ENV PORT=3000 EXPOSE 3000 +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + # 关键:确保复制了所有必要的文件,特别是 drizzle 文件夹 COPY --from=builder /app/public ./public COPY --from=builder /app/.next/standalone ./ diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index 7162c413f..5f418e753 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -52,6 +52,8 @@ services: condition: service_healthy environment: NODE_ENV: production + HOST: 0.0.0.0 + HOSTNAME: 0.0.0.0 DSN: postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-claude_code_hub} REDIS_URL: redis://redis:6379 AUTO_MIGRATE: ${AUTO_MIGRATE:-true} @@ -62,7 +64,13 @@ services: ports: - "${APP_PORT:-23000}:3000" healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:3000/api/actions/health || exit 1"] + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:3000/api/actions/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] interval: 15s timeout: 5s retries: 20 diff --git a/docker-compose.yaml b/docker-compose.yaml index a1fff2abe..66bb9f302 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -56,6 +56,8 @@ services: - ./.env environment: NODE_ENV: production + HOST: 0.0.0.0 + HOSTNAME: 0.0.0.0 # 容器内使用 Dockerfile 默认端口 3000,对外通过 APP_PORT 暴露(默认 23000) DSN: postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-claude_code_hub} REDIS_URL: redis://redis:6379 @@ -77,7 +79,7 @@ services: "CMD", "node", "-e", - "fetch('http://' + (process.env.HOSTNAME || '127.0.0.1') + ':3000/api/actions/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + "fetch('http://127.0.0.1:3000/api/actions/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", ] interval: 30s timeout: 5s diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 21db4cc9d..37860f4bd 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -778,6 +778,7 @@ "usageLogs": "Usage Logs", "leaderboard": "Leaderboard", "availability": "Availability", + "systemStatus": "System Status", "myQuota": "My Quota", "quotasManagement": "Quotas", "userManagement": "Users", @@ -1539,7 +1540,8 @@ "disabled": "Disabled" }, "actions": { - "addKey": "Add Key" + "addKey": "Add Key", + "addTemporaryKey": "Create Temp Keys" } }, "keyFullDisplay": { @@ -1622,6 +1624,68 @@ "createKeyTitle": "Create Key", "editKeyTitle": "Edit Key" }, + "temporaryKeys": { + "createDialog": { + "title": "Generate Temporary Keys", + "description": "Select a base key and generate temporary API keys under the current user group.", + "baseKeyLabel": "Base Key", + "baseKeyPlaceholder": "Select a base key", + "baseKeyRequired": "Please select a base key", + "groupNameLabel": "User Group", + "groupNamePlaceholder": "For example: campaign-april", + "quantityLabel": "Quantity", + "quantityDescription": "Quick generate 5 / 10 / 20 / 50 / 100 keys, or enter a custom amount.", + "customLimitLabel": "Per-key Total Limit (USD)", + "customLimitPlaceholder": "Leave blank to keep the base key total limit", + "customLimitDescription": "Only the total limit is overridden. All other settings follow the base key.", + "baseKeyRequiredHint": "Select a base key before generating.", + "baseKeyRequiredShort": "Select a base key", + "submit": "Generate", + "submitting": "Generating...", + "noKeys": "This user has no available base key", + "invalidCount": "Please enter a valid quantity", + "invalidLimit": "Please enter a valid total limit", + "genericError": "Failed to generate temporary keys" + }, + "success": { + "title": "Temporary Keys Ready", + "description": "Group {group} now contains {count} temporary keys. Download the full keys now and store them safely.", + "groupName": "User Group", + "sourceKey": "Base Key", + "previewTitle": "Preview (first 5)", + "download": "Download Keys" + }, + "listActions": { + "showMore": "Show remaining {count}", + "showLess": "Collapse" + }, + "sections": { + "standard": { + "title": "Standard Keys", + "description": "Primary business keys with the existing behavior unchanged.", + "empty": "No standard keys yet" + }, + "temporary": { + "title": "Temporary Keys", + "description": "Extra temporary CDKey groups for batch create, download, and delete under the current user group.", + "empty": "No temporary key groups yet" + } + }, + "groups": { + "title": "Temporary Key Groups", + "groupBadge": "Temp", + "count": "{count} keys", + "download": "Download Keys", + "delete": "Delete Group" + }, + "toasts": { + "createSuccess": "Generated {count} temporary keys", + "createFailed": "Failed to generate temporary keys: {error}", + "deleteSuccess": "Deleted group {group} ({count} keys)", + "deleteFailed": "Failed to delete temporary key group: {error}", + "downloadFailed": "Failed to download temporary keys: {error}" + } + }, "editDialog": { "title": "Edit user", "description": "Edit user information", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index f3b3a9561..bc8bc18de 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -776,6 +776,7 @@ "usageLogs": "使用ログ", "leaderboard": "ランキング", "availability": "可用性監視", + "systemStatus": "システム状態", "myQuota": "自分のクォータ", "quotasManagement": "クォータ管理", "userManagement": "ユーザー", @@ -1521,7 +1522,8 @@ "disabled": "無効" }, "actions": { - "addKey": "キーを追加" + "addKey": "キーを追加", + "addTemporaryKey": "一時キー" } }, "keyFullDisplay": { @@ -1656,6 +1658,52 @@ "success": "すべての統計がリセットされました" } }, + "temporaryKeys": { + "createDialog": { + "title": "一時キーを一括生成", + "description": "ベースキーを選択し、専用グループに一時 API キーをまとめて生成します。", + "baseKeyLabel": "ベースキー", + "baseKeyPlaceholder": "ベースキーを選択", + "baseKeyRequired": "ベースキーを選択してください", + "groupNameLabel": "グループ名", + "groupNamePlaceholder": "例:campaign-april", + "quantityLabel": "生成数", + "quantityDescription": "5 / 10 / 20 / 50 / 100 の即時生成、または任意の数を指定できます。", + "customLimitLabel": "各キーの総上限 (USD)", + "customLimitPlaceholder": "空欄でベースキーの総上限を使用", + "customLimitDescription": "総上限のみ上書きします。その他の設定はベースキーに従います。", + "baseKeyRequiredHint": "生成前にベースキーを選択してください。", + "baseKeyRequiredShort": "ベースキーを選択してください", + "submit": "生成", + "submitting": "生成中...", + "noKeys": "利用可能なベースキーがありません", + "invalidCount": "有効な生成数を入力してください", + "invalidLimit": "有効な総上限を入力してください", + "genericError": "一時キーの生成に失敗しました" + }, + "success": { + "title": "一時キーを生成しました", + "description": "グループ {group} に {count} 件の一時キーを生成しました。完全なキーはすぐにダウンロードして安全に保管してください。", + "groupName": "グループ名", + "sourceKey": "ベースキー", + "previewTitle": "プレビュー(先頭 5 件)", + "download": "CSV をダウンロード" + }, + "groups": { + "title": "一時キーグループ", + "groupBadge": "Temp", + "count": "{count} keys", + "download": "ダウンロード", + "delete": "グループ削除" + }, + "toasts": { + "createSuccess": "{count} 件の一時キーを生成しました", + "createFailed": "一時キーの生成に失敗しました: {error}", + "deleteSuccess": "グループ {group} を削除しました({count} keys)", + "deleteFailed": "一時キーグループの削除に失敗しました: {error}", + "downloadFailed": "一時キーのダウンロードに失敗しました: {error}" + } + }, "batchEdit": { "enterMode": "一括編集", "exitMode": "終了", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 8543647fc..9bed8658f 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -779,6 +779,7 @@ "usageLogs": "Журналы", "leaderboard": "Лидеры", "availability": "Мониторинг", + "systemStatus": "Статус системы", "myQuota": "Моя квота", "quotasManagement": "Квоты", "userManagement": "Пользователи", @@ -1522,7 +1523,8 @@ "disabled": "Отключен" }, "actions": { - "addKey": "Добавить ключ" + "addKey": "Добавить ключ", + "addTemporaryKey": "Временные ключи" } }, "keyFullDisplay": { @@ -1605,6 +1607,52 @@ "createKeyTitle": "Создать ключ", "editKeyTitle": "Редактировать ключ" }, + "temporaryKeys": { + "createDialog": { + "title": "Пакетная генерация временных ключей", + "description": "Выберите базовый ключ и массово создайте временные API-ключи в отдельной группе.", + "baseKeyLabel": "Базовый ключ", + "baseKeyPlaceholder": "Выберите базовый ключ", + "baseKeyRequired": "Выберите базовый ключ", + "groupNameLabel": "Имя группы", + "groupNamePlaceholder": "Например: campaign-april", + "quantityLabel": "Количество", + "quantityDescription": "Быстрая генерация 5 / 10 / 20 / 50 / 100 ключей или ввод своего количества.", + "customLimitLabel": "Общий лимит на ключ (USD)", + "customLimitPlaceholder": "Оставьте пустым, чтобы использовать лимит базового ключа", + "customLimitDescription": "Переопределяется только общий лимит. Остальные настройки копируются с базового ключа.", + "baseKeyRequiredHint": "Перед генерацией выберите базовый ключ.", + "baseKeyRequiredShort": "Выберите базовый ключ", + "submit": "Сгенерировать", + "submitting": "Генерация...", + "noKeys": "У пользователя нет доступного базового ключа", + "invalidCount": "Введите корректное количество", + "invalidLimit": "Введите корректный общий лимит", + "genericError": "Не удалось сгенерировать временные ключи" + }, + "success": { + "title": "Временные ключи готовы", + "description": "В группе {group} создано {count} временных ключей. Сразу скачайте полный список ключей и сохраните его.", + "groupName": "Имя группы", + "sourceKey": "Базовый ключ", + "previewTitle": "Предпросмотр (первые 5)", + "download": "Скачать CSV" + }, + "groups": { + "title": "Группы временных ключей", + "groupBadge": "Temp", + "count": "{count} keys", + "download": "Скачать", + "delete": "Удалить группу" + }, + "toasts": { + "createSuccess": "Сгенерировано временных ключей: {count}", + "createFailed": "Не удалось сгенерировать временные ключи: {error}", + "deleteSuccess": "Группа {group} удалена ({count} keys)", + "deleteFailed": "Не удалось удалить группу временных ключей: {error}", + "downloadFailed": "Не удалось скачать временные ключи: {error}" + } + }, "editDialog": { "title": "Редактировать пользователя", "description": "Редактирование данных пользователя", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 347b01908..a9e6f4cff 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -779,6 +779,7 @@ "usageLogs": "使用记录", "leaderboard": "排行榜", "availability": "可用性监控", + "systemStatus": "系统状态", "myQuota": "我的配额", "quotasManagement": "限额管理", "providers": "供应商管理", @@ -1540,7 +1541,8 @@ "disabled": "已禁用" }, "actions": { - "addKey": "新增密钥" + "addKey": "新增密钥", + "addTemporaryKey": "创建临时 Key" } }, "keyFullDisplay": { @@ -1623,6 +1625,68 @@ "createKeyTitle": "创建 Key", "editKeyTitle": "编辑 Key" }, + "temporaryKeys": { + "createDialog": { + "title": "批量生成临时 Key", + "description": "选择一个基础 Key,并按当前用户组批量生成临时 API Key。", + "baseKeyLabel": "基础 Key", + "baseKeyPlaceholder": "请选择基础 Key", + "baseKeyRequired": "请选择基础 Key", + "groupNameLabel": "用户组别", + "groupNamePlaceholder": "例如:渠道A-4月活动", + "quantityLabel": "生成数量", + "quantityDescription": "支持快捷生成 5 / 10 / 20 / 50 / 100,也可自定义数量。", + "customLimitLabel": "每个 Key 总额度 (USD)", + "customLimitPlaceholder": "留空则沿用基础 Key 的总额度配置", + "customLimitDescription": "仅覆写总额度,其余配置保持和基础 Key 一致。", + "baseKeyRequiredHint": "请选择一个基础 Key 后再生成。", + "baseKeyRequiredShort": "请选择基础 Key", + "submit": "立即生成", + "submitting": "生成中...", + "noKeys": "当前用户暂无可用的基础 Key", + "invalidCount": "请输入有效的生成数量", + "invalidLimit": "请输入有效的总额度", + "genericError": "批量生成失败" + }, + "success": { + "title": "临时 Key 生成完成", + "description": "分组 {group} 已成功生成 {count} 个临时 Key,请立即下载保存完整密钥。", + "groupName": "用户组别", + "sourceKey": "基础 Key", + "previewTitle": "预览(前 5 条)", + "download": "下载纯 Key" + }, + "listActions": { + "showMore": "展开其余 {count} 个", + "showLess": "收起" + }, + "sections": { + "standard": { + "title": "常规 Key", + "description": "主业务 Key 管理区,保持原有逻辑不变。", + "empty": "暂无常规 Key" + }, + "temporary": { + "title": "临时 Key", + "description": "额外的临时 CDKey 分组,按当前用户组批量创建、下载和删除。", + "empty": "暂无临时 Key 分组" + } + }, + "groups": { + "title": "临时 Key 分组", + "groupBadge": "临时", + "count": "{count} 个 Key", + "download": "下载纯 Key", + "delete": "删除分组" + }, + "toasts": { + "createSuccess": "已生成 {count} 个临时 Key", + "createFailed": "生成临时 Key 失败:{error}", + "deleteSuccess": "已删除分组 {group}({count} 个 Key)", + "deleteFailed": "删除临时 Key 分组失败:{error}", + "downloadFailed": "下载临时 Key 失败:{error}" + } + }, "editDialog": { "title": "编辑用户", "description": "编辑用户信息", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index b2a41eebb..baacf086d 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -776,6 +776,7 @@ "usageLogs": "使用記錄", "leaderboard": "排行", "availability": "可用性監控", + "systemStatus": "系統狀態", "myQuota": "我的額度", "quotasManagement": "額度管理", "userManagement": "使用者管理", @@ -1525,7 +1526,8 @@ "disabled": "已停用" }, "actions": { - "addKey": "新增金鑰" + "addKey": "新增金鑰", + "addTemporaryKey": "臨時 Key" } }, "keyFullDisplay": { @@ -1608,6 +1610,52 @@ "createKeyTitle": "建立 Key", "editKeyTitle": "編輯 Key" }, + "temporaryKeys": { + "createDialog": { + "title": "批量產生臨時 Key", + "description": "選擇一個基礎 Key,並按分組批量產生臨時 API Key。", + "baseKeyLabel": "基礎 Key", + "baseKeyPlaceholder": "請選擇基礎 Key", + "baseKeyRequired": "請選擇基礎 Key", + "groupNameLabel": "分組名稱", + "groupNamePlaceholder": "例如:渠道A-4月活動", + "quantityLabel": "產生數量", + "quantityDescription": "支援快速產生 5 / 10 / 20 / 50 / 100,也可自訂數量。", + "customLimitLabel": "每個 Key 總額度 (USD)", + "customLimitPlaceholder": "留空則沿用基礎 Key 的總額度設定", + "customLimitDescription": "僅覆寫總額度,其餘設定保持和基礎 Key 一致。", + "baseKeyRequiredHint": "請先選擇基礎 Key 再產生。", + "baseKeyRequiredShort": "請選擇基礎 Key", + "submit": "立即產生", + "submitting": "產生中...", + "noKeys": "目前使用者沒有可用的基礎 Key", + "invalidCount": "請輸入有效的產生數量", + "invalidLimit": "請輸入有效的總額度", + "genericError": "批量產生失敗" + }, + "success": { + "title": "臨時 Key 已產生", + "description": "分組 {group} 已成功產生 {count} 個臨時 Key,請立即下載保存完整金鑰。", + "groupName": "分組名稱", + "sourceKey": "基礎 Key", + "previewTitle": "預覽(前 5 條)", + "download": "下載 CSV" + }, + "groups": { + "title": "臨時 Key 分組", + "groupBadge": "臨時", + "count": "{count} 個 Key", + "download": "下載", + "delete": "刪除分組" + }, + "toasts": { + "createSuccess": "已產生 {count} 個臨時 Key", + "createFailed": "產生臨時 Key 失敗:{error}", + "deleteSuccess": "已刪除分組 {group}({count} 個 Key)", + "deleteFailed": "刪除臨時 Key 分組失敗:{error}", + "downloadFailed": "下載臨時 Key 失敗:{error}" + } + }, "editDialog": { "title": "編輯使用者", "description": "編輯使用者資訊", diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 index edc8c1c1c..38bec454c 100644 --- a/scripts/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -424,6 +424,8 @@ services: - ./.env environment: NODE_ENV: production + HOST: 0.0.0.0 + HOSTNAME: 0.0.0.0 PORT: `${APP_PORT:-$($script:APP_PORT)} DSN: postgresql://`${DB_USER:-postgres}:`${DB_PASSWORD:-postgres}@claude-code-hub-db-${SUFFIX}:5432/`${DB_NAME:-claude_code_hub} REDIS_URL: redis://claude-code-hub-redis-${SUFFIX}:6379 @@ -436,7 +438,7 @@ $appPortsSection networks: - claude-code-hub-net-$SUFFIX healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:`${APP_PORT:-$($script:APP_PORT)}/api/actions/health || exit 1"] + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:' + (process.env.PORT || '$($script:APP_PORT)') + '/api/actions/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] interval: 30s timeout: 5s retries: 3 diff --git a/scripts/deploy.sh b/scripts/deploy.sh index e94183708..6249d83b4 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -509,6 +509,8 @@ services: - ./.env environment: NODE_ENV: production + HOST: 0.0.0.0 + HOSTNAME: 0.0.0.0 PORT: \${APP_PORT:-${APP_PORT}} DSN: postgresql://\${DB_USER:-postgres}:\${DB_PASSWORD:-postgres}@claude-code-hub-db-${SUFFIX}:5432/\${DB_NAME:-claude_code_hub} REDIS_URL: redis://claude-code-hub-redis-${SUFFIX}:6379 @@ -531,7 +533,7 @@ EOF networks: - claude-code-hub-net-${SUFFIX} healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:\${APP_PORT:-${APP_PORT}}/api/actions/health || exit 1"] + 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))"] interval: 30s timeout: 5s retries: 3 diff --git a/scripts/server-zero-downtime-rollout.sh b/scripts/server-zero-downtime-rollout.sh new file mode 100644 index 000000000..a5365adc6 --- /dev/null +++ b/scripts/server-zero-downtime-rollout.sh @@ -0,0 +1,660 @@ +#!/usr/bin/env bash + +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { + echo -e "${BLUE}[INFO]${NC} $*" +} + +log_success() { + echo -e "${GREEN}[OK]${NC} $*" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $*" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $*" >&2 +} + +usage() { + cat <<'EOF' +Zero-downtime Docker rollout for server-side deployments. + +Usage: + server-zero-downtime-rollout.sh --image-tag [options] + +Required: + --image-tag Image tag already available on the server + +Optional: + --deploy-dir Deployment directory containing docker-compose.run.yml and .env + --compose-file Compose file path + --env-file Environment file path + --nginx-config Nginx config file containing the target domain block + --domain Domain served by the target Nginx block + --health-path Health path, default: /api/actions/health + --standard-port Standard local port, default: APP_PORT from .env or 23000 + --green-port Temporary green port for fallback path, default: standard+1 + --app-service Compose app service name, default: app + --backup-root Backup root, default: /opt/backups/claude-code-hub + --current-tag Runtime tag to retag for compose app, default: claude-code-hub-local:current + --green-name Manual green container name for fallback path + --keep-old-running Keep old live container running after cutover (default) + --stop-old-after-cutover Stop old live container after successful cutover + --local-timeout Local health timeout, default: 90 + --public-timeout Public health timeout, default: 45 + --dry-run Print the rollout plan without changing runtime state + -h, --help Show this help + +Behavior: + 1. Detect current live port from nginx and verify current public health + 2. Backup nginx / compose / env and current image tag + 3. Retag current image to the requested image tag + 4. Prefer compose on the standard port when it is free or can be reclaimed safely + 5. Otherwise, start a manual green container on a temporary port and cut nginx there + 6. Keep the old live container by default so rollback stays fast and low-risk +EOF +} + +IMAGE_TAG="" +DEPLOY_DIR="/opt/apps/claude-code-hub-local" +COMPOSE_FILE="" +ENV_FILE="" +NGINX_CONFIG="/etc/nginx/sites-enabled/fkcodex-apps.conf" +DOMAIN="cch.fkcodex.com" +HEALTH_PATH="/api/actions/health" +STANDARD_PORT="" +GREEN_PORT="" +APP_SERVICE="app" +BACKUP_ROOT="/opt/backups/claude-code-hub" +CURRENT_TAG="claude-code-hub-local:current" +GREEN_NAME="" +KEEP_OLD_RUNNING=true +COMPOSE_OVERRIDE_FILE="" +LOCAL_HEALTH_TIMEOUT=90 +PUBLIC_HEALTH_TIMEOUT=45 +DRY_RUN=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --image-tag) + IMAGE_TAG="${2:-}" + shift 2 + ;; + --deploy-dir) + DEPLOY_DIR="${2:-}" + shift 2 + ;; + --compose-file) + COMPOSE_FILE="${2:-}" + shift 2 + ;; + --env-file) + ENV_FILE="${2:-}" + shift 2 + ;; + --nginx-config) + NGINX_CONFIG="${2:-}" + shift 2 + ;; + --domain) + DOMAIN="${2:-}" + shift 2 + ;; + --health-path) + HEALTH_PATH="${2:-}" + shift 2 + ;; + --standard-port) + STANDARD_PORT="${2:-}" + shift 2 + ;; + --green-port) + GREEN_PORT="${2:-}" + shift 2 + ;; + --app-service) + APP_SERVICE="${2:-}" + shift 2 + ;; + --backup-root) + BACKUP_ROOT="${2:-}" + shift 2 + ;; + --current-tag) + CURRENT_TAG="${2:-}" + shift 2 + ;; + --green-name) + GREEN_NAME="${2:-}" + shift 2 + ;; + --keep-old-running) + KEEP_OLD_RUNNING=true + shift + ;; + --stop-old-after-cutover) + KEEP_OLD_RUNNING=false + shift + ;; + --local-timeout) + LOCAL_HEALTH_TIMEOUT="${2:-}" + shift 2 + ;; + --public-timeout) + PUBLIC_HEALTH_TIMEOUT="${2:-}" + shift 2 + ;; + --dry-run) + DRY_RUN=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + log_error "Unknown option: $1" + usage + exit 1 + ;; + esac +done + +if [[ -z "$IMAGE_TAG" ]]; then + log_error "--image-tag is required" + usage + exit 1 +fi + +if [[ -z "$COMPOSE_FILE" ]]; then + COMPOSE_FILE="$DEPLOY_DIR/docker-compose.run.yml" +fi + +if [[ -z "$ENV_FILE" ]]; then + ENV_FILE="$DEPLOY_DIR/.env" +fi + +for cmd in docker curl python3 nginx ss; do + command -v "$cmd" >/dev/null 2>&1 || { + log_error "Required command not found: $cmd" + exit 1 + } +done + +docker compose version >/dev/null 2>&1 || { + log_error "docker compose plugin is required" + exit 1 +} + +for path in "$DEPLOY_DIR" "$COMPOSE_FILE" "$ENV_FILE" "$NGINX_CONFIG"; do + [[ -e "$path" ]] || { + log_error "Required path not found: $path" + exit 1 + } +done + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +if [[ -z "$STANDARD_PORT" ]]; then + STANDARD_PORT="${APP_PORT:-23000}" +fi + +if [[ -z "$GREEN_PORT" ]]; then + GREEN_PORT="$((STANDARD_PORT + 1))" +fi + +if [[ -z "$GREEN_NAME" ]]; then + GREEN_NAME="${COMPOSE_PROJECT_NAME:-claude-code-hub-local}_rollout_green" +fi + +PROJECT_NAME="${COMPOSE_PROJECT_NAME:-claude-code-hub-local}" +DOCKER_NETWORK="${PROJECT_NAME}_default" +BACKUP_TIMESTAMP="$(date +%Y%m%dT%H%M%S)" +BACKUP_DIR="${BACKUP_ROOT}/${BACKUP_TIMESTAMP}" +NGINX_BACKUP="${BACKUP_DIR}/$(basename "$NGINX_CONFIG")" +COMPOSE_OVERRIDE_FILE="${BACKUP_DIR}/docker-compose.rollout.override.yml" +CUTOVER_DONE=false +LOCK_DIR="/tmp/${PROJECT_NAME//[^a-zA-Z0-9_.-]/_}.${DOMAIN//[^a-zA-Z0-9_.-]/_}.rollout.lock" +ROLLBACK_ATTEMPTED=false + +acquire_lock() { + if mkdir "$LOCK_DIR" 2>/dev/null; then + return 0 + fi + log_error "Another rollout appears to be running: $LOCK_DIR" + exit 1 +} + +release_lock() { + rmdir "$LOCK_DIR" >/dev/null 2>&1 || true +} + +get_domain_proxy_port() { + python3 - "$NGINX_CONFIG" "$DOMAIN" <<'PY' +import sys +from pathlib import Path + +config = Path(sys.argv[1]).read_text().splitlines(keepends=True) +domain = sys.argv[2] + +def iter_server_blocks(lines): + in_server = False + depth = 0 + block = [] + for line in lines: + stripped = line.strip() + if not in_server and stripped.startswith("server") and "{" in stripped: + in_server = True + block = [line] + depth = line.count("{") - line.count("}") + if depth == 0: + yield "".join(block) + in_server = False + continue + if in_server: + block.append(line) + depth += line.count("{") - line.count("}") + if depth == 0: + yield "".join(block) + in_server = False + +for block in iter_server_blocks(config): + if f"server_name {domain};" in block: + for line in block.splitlines(): + if "proxy_pass http://127.0.0.1:" in line: + value = line.split("proxy_pass http://127.0.0.1:", 1)[1].split(";", 1)[0].strip() + print(value) + sys.exit(0) + +sys.exit(1) +PY +} + +set_domain_proxy_port() { + local new_port="$1" + python3 - "$NGINX_CONFIG" "$DOMAIN" "$new_port" <<'PY' +import sys +from pathlib import Path + +path = Path(sys.argv[1]) +domain = sys.argv[2] +new_port = sys.argv[3] +lines = path.read_text().splitlines(keepends=True) + +output = [] +block = [] +in_server = False +depth = 0 +updated = False + +def rewrite_block(block_text): + global updated + if f"server_name {domain};" not in block_text: + return block_text + if "proxy_pass http://127.0.0.1:" not in block_text: + return block_text + if updated: + raise SystemExit("multiple matching server blocks found") + rewritten_lines = [] + replaced = False + for line in block_text.splitlines(keepends=True): + if "proxy_pass http://127.0.0.1:" in line and not replaced: + prefix = line.split("proxy_pass http://127.0.0.1:", 1)[0] + suffix = "\n" if line.endswith("\n") else "" + rewritten_lines.append(f"{prefix}proxy_pass http://127.0.0.1:{new_port};{suffix}") + replaced = True + else: + rewritten_lines.append(line) + if not replaced: + raise SystemExit("failed to update target server block") + updated = True + return "".join(rewritten_lines) + +for line in lines: + stripped = line.strip() + if not in_server and stripped.startswith("server") and "{" in stripped: + in_server = True + block = [line] + depth = line.count("{") - line.count("}") + if depth == 0: + output.append(rewrite_block("".join(block))) + in_server = False + continue + if in_server: + block.append(line) + depth += line.count("{") - line.count("}") + if depth == 0: + output.append(rewrite_block("".join(block))) + in_server = False + continue + output.append(line) + +if in_server: + raise SystemExit("unterminated server block") +if not updated: + raise SystemExit("target domain block not found") + +path.write_text("".join(output)) +PY +} + +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} + ' +} + +port_is_free() { + local port="$1" + ! ss -ltnH "( sport = :${port} )" | grep -q . +} + +wait_http_ok() { + local url="$1" + local timeout="${2:-90}" + local start + start="$(date +%s)" + while true; do + if curl -fsS "$url" >/dev/null 2>&1; then + return 0 + fi + if (( "$(date +%s)" - start >= timeout )); then + return 1 + fi + sleep 1 + done +} + +backup_runtime() { + if [[ "$DRY_RUN" == true ]]; then + log_info "[dry-run] Would create runtime backup: $BACKUP_DIR" + return 0 + fi + mkdir -p "$BACKUP_DIR" + cp "$NGINX_CONFIG" "$NGINX_BACKUP" + cp "$COMPOSE_FILE" "$BACKUP_DIR/" + cp "$ENV_FILE" "$BACKUP_DIR/" + docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}\t{{.Status}}' >"$BACKUP_DIR/docker-ps.txt" + docker images --format 'table {{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.Size}}' >"$BACKUP_DIR/docker-images.txt" + printf '%s\n' "$IMAGE_TAG" >"$BACKUP_DIR/requested-image-tag.txt" + printf '%s\n' "$CURRENT_TAG" >"$BACKUP_DIR/current-runtime-tag.txt" + if docker image inspect "$CURRENT_TAG" >/dev/null 2>&1; then + docker tag "$CURRENT_TAG" "${CURRENT_TAG%:*}:rollback-${BACKUP_TIMESTAMP}" + fi +} + +restore_proxy_backup() { + if [[ ! -f "$NGINX_BACKUP" ]]; then + log_error "Nginx backup not found: $NGINX_BACKUP" + exit 1 + fi + cp "$NGINX_BACKUP" "$NGINX_CONFIG" + if ! nginx -t >/dev/null 2>&1; then + log_error "Failed to restore nginx backup cleanly: $NGINX_BACKUP" + exit 1 + fi + nginx -s reload >/dev/null +} + +cutover_proxy() { + local target_port="$1" + if [[ "$DRY_RUN" == true ]]; then + log_info "[dry-run] Would switch nginx traffic to 127.0.0.1:${target_port}" + return 0 + fi + set_domain_proxy_port "$target_port" + if ! nginx -t >/dev/null 2>&1; then + cp "$NGINX_BACKUP" "$NGINX_CONFIG" + log_error "nginx -t failed after editing config, restored backup" + exit 1 + fi + nginx -s reload >/dev/null + CUTOVER_DONE=true +} + +stop_old_live_container() { + local name="$1" + if [[ -n "$name" && "$KEEP_OLD_RUNNING" == false ]]; then + if [[ "$DRY_RUN" == true ]]; then + log_info "[dry-run] Would stop previous live container: $name" + return 0 + fi + docker stop "$name" >/dev/null || true + log_info "Stopped previous live container: $name" + elif [[ -n "$name" ]]; then + log_info "Keeping previous live container for rollback: $name" + fi +} + +stop_non_live_standard_holder() { + local name="$1" + if [[ -z "$name" ]]; then + return 0 + fi + if [[ "$DRY_RUN" == true ]]; then + log_info "[dry-run] Would stop non-live container occupying standard port: $name" + return 0 + fi + docker stop "$name" >/dev/null + log_info "Stopped non-live container occupying standard port: $name" +} + +start_compose_on_standard_port() { + log_info "Starting compose app service on standard port ${STANDARD_PORT}" + if [[ "$DRY_RUN" == true ]]; then + log_info "[dry-run] Would start compose app ${APP_SERVICE} on standard port ${STANDARD_PORT} using ${CURRENT_TAG}" + return 0 + fi + cat >"$COMPOSE_OVERRIDE_FILE" </dev/null +} + +start_manual_green() { + local candidate_port="$1" + local dsn="postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-claude_code_hub}" + local redis_url="redis://redis:6379" + + log_info "Starting manual green container ${GREEN_NAME} on port ${candidate_port}" + if [[ "$DRY_RUN" == true ]]; then + log_info "[dry-run] Would start manual green container ${GREEN_NAME} on ${candidate_port}" + return 0 + fi + docker rm -f "$GREEN_NAME" >/dev/null 2>&1 || true + docker run -d \ + --name "$GREEN_NAME" \ + --restart unless-stopped \ + --network "$DOCKER_NETWORK" \ + --env-file "$ENV_FILE" \ + -e HOST=0.0.0.0 \ + -e HOSTNAME=0.0.0.0 \ + -e NODE_ENV=production \ + -e DSN="$dsn" \ + -e REDIS_URL="$redis_url" \ + -e AUTO_MIGRATE=false \ + -e APP_PORT="$candidate_port" \ + -p "127.0.0.1:${candidate_port}:3000" \ + --health-cmd "node -e \"fetch('http://127.0.0.1:3000${HEALTH_PATH}').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" \ + --health-interval 30s \ + --health-timeout 5s \ + --health-retries 3 \ + --health-start-period 30s \ + "$IMAGE_TAG" >/dev/null +} + +verify_public_health() { + local url="https://${DOMAIN}${HEALTH_PATH}" + wait_http_ok "$url" "$PUBLIC_HEALTH_TIMEOUT" || { + log_error "Public health check failed: $url" + if [[ "$CUTOVER_DONE" == true && "$DRY_RUN" == false ]]; then + log_warn "Rolling nginx back to previous live port" + restore_proxy_backup + ROLLBACK_ATTEMPTED=true + fi + exit 1 + } +} + +verify_live_public_before_rollout() { + local url="https://${DOMAIN}${HEALTH_PATH}" + wait_http_ok "$url" "$PUBLIC_HEALTH_TIMEOUT" || { + log_error "Current public health is not healthy, aborting rollout: $url" + exit 1 + } +} + +print_plan_and_exit() { + local mode="$1" + echo + log_info "Dry run only. No changes were made." + echo " requested image: $IMAGE_TAG" + echo " current live: ${LIVE_CONTAINER:-} on ${LIVE_PORT}" + echo " standard port: $STANDARD_PORT" + echo " standard holder: ${STANDARD_PORT_CONTAINER:-}" + echo " selected mode: $mode" + echo " keep old live: $KEEP_OLD_RUNNING" + echo " backup dir: $BACKUP_DIR" + exit 0 +} + +handle_exit() { + local status="$1" + release_lock + if [[ "$status" -ne 0 && "$CUTOVER_DONE" == true && "$DRY_RUN" == false && "$ROLLBACK_ATTEMPTED" == false && -f "$NGINX_BACKUP" ]]; then + log_warn "Failure detected after cutover, restoring nginx backup" + cp "$NGINX_BACKUP" "$NGINX_CONFIG" >/dev/null 2>&1 || true + nginx -t >/dev/null 2>&1 && nginx -s reload >/dev/null 2>&1 || true + fi +} + +acquire_lock +trap 'handle_exit $?' EXIT + +LIVE_PORT="$(get_domain_proxy_port)" +LIVE_CONTAINER="$(find_container_by_host_port "$LIVE_PORT" || true)" +STANDARD_PORT_CONTAINER="$(find_container_by_host_port "$STANDARD_PORT" || true)" + +log_info "Current live domain: ${DOMAIN}" +log_info "Current live port: ${LIVE_PORT}" +log_info "Current live container: ${LIVE_CONTAINER:-}" +log_info "Standard port: ${STANDARD_PORT}" +log_info "Green fallback port: ${GREEN_PORT}" +log_info "Keep old live container after cutover: ${KEEP_OLD_RUNNING}" + +docker image inspect "$IMAGE_TAG" >/dev/null 2>&1 || { + log_error "Image tag not found on server: $IMAGE_TAG" + exit 1 +} + +verify_live_public_before_rollout + +if [[ "$DRY_RUN" == true ]]; then + if [[ "$LIVE_PORT" != "$STANDARD_PORT" ]] && port_is_free "$STANDARD_PORT"; then + print_plan_and_exit "compose-standard-free" + elif [[ "$LIVE_PORT" != "$STANDARD_PORT" ]] && [[ -n "$STANDARD_PORT_CONTAINER" && "$STANDARD_PORT_CONTAINER" != "$LIVE_CONTAINER" ]]; then + print_plan_and_exit "compose-standard-reclaim" + elif [[ "$LIVE_PORT" != "$STANDARD_PORT" ]] && wait_http_ok "http://127.0.0.1:${STANDARD_PORT}${HEALTH_PATH}" 2; then + print_plan_and_exit "cut-back-to-running-standard" + else + print_plan_and_exit "manual-green" + fi +fi + +backup_runtime +log_success "Runtime backup created: $BACKUP_DIR" + +docker tag "$IMAGE_TAG" "$CURRENT_TAG" +log_info "Retagged ${IMAGE_TAG} -> ${CURRENT_TAG}" + +if [[ "$LIVE_PORT" != "$STANDARD_PORT" ]] && port_is_free "$STANDARD_PORT"; then + start_compose_on_standard_port + wait_http_ok "http://127.0.0.1:${STANDARD_PORT}${HEALTH_PATH}" "$LOCAL_HEALTH_TIMEOUT" || { + log_error "Compose app failed local health check on standard port ${STANDARD_PORT}" + exit 1 + } + cutover_proxy "$STANDARD_PORT" + verify_public_health + stop_old_live_container "$LIVE_CONTAINER" + log_success "Traffic switched to compose app on standard port ${STANDARD_PORT}" + log_info "Runtime ownership: compose-managed" +elif [[ "$LIVE_PORT" != "$STANDARD_PORT" ]] && [[ -n "$STANDARD_PORT_CONTAINER" && "$STANDARD_PORT_CONTAINER" != "$LIVE_CONTAINER" ]]; then + log_info "Standard port ${STANDARD_PORT} is occupied by non-live container ${STANDARD_PORT_CONTAINER}, reclaiming it" + stop_non_live_standard_holder "$STANDARD_PORT_CONTAINER" + port_is_free "$STANDARD_PORT" || { + log_error "Standard port is still in use after reclaim attempt: ${STANDARD_PORT}" + exit 1 + } + start_compose_on_standard_port + wait_http_ok "http://127.0.0.1:${STANDARD_PORT}${HEALTH_PATH}" "$LOCAL_HEALTH_TIMEOUT" || { + log_error "Compose app failed local health check on reclaimed standard port ${STANDARD_PORT}" + exit 1 + } + cutover_proxy "$STANDARD_PORT" + verify_public_health + stop_old_live_container "$LIVE_CONTAINER" + log_success "Traffic switched to compose app on reclaimed standard port ${STANDARD_PORT}" + log_info "Runtime ownership: compose-managed" +elif [[ "$LIVE_PORT" != "$STANDARD_PORT" ]] && wait_http_ok "http://127.0.0.1:${STANDARD_PORT}${HEALTH_PATH}" 2; then + log_info "Standard port ${STANDARD_PORT} is already healthy, cutting traffic back without restarting app" + cutover_proxy "$STANDARD_PORT" + verify_public_health + stop_old_live_container "$LIVE_CONTAINER" + log_success "Traffic switched to already-running app on standard port ${STANDARD_PORT}" + log_info "Runtime ownership: compose-managed" +else + if [[ "$GREEN_PORT" == "$LIVE_PORT" || "$GREEN_PORT" == "$STANDARD_PORT" ]]; then + GREEN_PORT="$((STANDARD_PORT + 2))" + fi + port_is_free "$GREEN_PORT" || { + log_error "Green port is already in use: $GREEN_PORT" + exit 1 + } + start_manual_green "$GREEN_PORT" + wait_http_ok "http://127.0.0.1:${GREEN_PORT}${HEALTH_PATH}" "$LOCAL_HEALTH_TIMEOUT" || { + log_error "Manual green container failed local health check on ${GREEN_PORT}" + exit 1 + } + cutover_proxy "$GREEN_PORT" + verify_public_health + stop_old_live_container "$LIVE_CONTAINER" + if [[ "$LIVE_PORT" == "$STANDARD_PORT" ]]; then + log_warn "Traffic is healthy on temporary green port ${GREEN_PORT}, but standard port normalization is still pending" + else + log_warn "Standard port ${STANDARD_PORT} was unavailable, kept traffic on temporary green port ${GREEN_PORT}" + fi + log_info "Runtime ownership: manual green container" +fi + +FINAL_PORT="$(get_domain_proxy_port)" +FINAL_CONTAINER="$(find_container_by_host_port "$FINAL_PORT" || true)" + +echo +log_success "Rollout complete" +echo " image tag: $IMAGE_TAG" +echo " current tag: $CURRENT_TAG" +echo " live port: $FINAL_PORT" +echo " live container: ${FINAL_CONTAINER:-}" +echo " nginx backup: $NGINX_BACKUP" +echo " runtime backup: $BACKUP_DIR" diff --git a/src/actions/keys.ts b/src/actions/keys.ts index 6d3caf8e8..16f0f3412 100644 --- a/src/actions/keys.ts +++ b/src/actions/keys.ts @@ -22,7 +22,9 @@ import { toKey } from "@/repository/_shared/transformers"; import type { KeyStatistics } from "@/repository/key"; import { countActiveKeysByUser, + createKeysBatch, createKey, + deleteKeysBatch, deleteKey, findActiveKeyByUserIdAndName, findKeyById, @@ -64,6 +66,142 @@ function validateNonAdminProviderGroup( return requestedProviderGroup; } +type TemporaryKeyLimitValidationInput = { + limit5hUsd?: number | null; + limitDailyUsd?: number | null; + limitWeeklyUsd?: number | null; + limitMonthlyUsd?: number | null; + limitTotalUsd?: number | null; + limitConcurrentSessions?: number | null; +}; + +function validateTemporaryKeyLimitsAgainstUser( + user: { + limit5hUsd?: number | null; + dailyQuota?: number | null; + limitWeeklyUsd?: number | null; + limitMonthlyUsd?: number | null; + limitTotalUsd?: number | null; + limitConcurrentSessions?: number | null; + }, + limits: TemporaryKeyLimitValidationInput, + tError: TranslationFunction +): string | null { + if ( + limits.limit5hUsd != null && + limits.limit5hUsd > 0 && + user.limit5hUsd != null && + user.limit5hUsd > 0 && + limits.limit5hUsd > user.limit5hUsd + ) { + return tError("KEY_LIMIT_5H_EXCEEDS_USER_LIMIT", { + keyLimit: String(limits.limit5hUsd), + userLimit: String(user.limit5hUsd), + }); + } + + if ( + limits.limitDailyUsd != null && + limits.limitDailyUsd > 0 && + user.dailyQuota != null && + user.dailyQuota > 0 && + limits.limitDailyUsd > user.dailyQuota + ) { + return tError("KEY_LIMIT_DAILY_EXCEEDS_USER_LIMIT", { + keyLimit: String(limits.limitDailyUsd), + userLimit: String(user.dailyQuota), + }); + } + + if ( + limits.limitWeeklyUsd != null && + limits.limitWeeklyUsd > 0 && + user.limitWeeklyUsd != null && + user.limitWeeklyUsd > 0 && + limits.limitWeeklyUsd > user.limitWeeklyUsd + ) { + return tError("KEY_LIMIT_WEEKLY_EXCEEDS_USER_LIMIT", { + keyLimit: String(limits.limitWeeklyUsd), + userLimit: String(user.limitWeeklyUsd), + }); + } + + if ( + limits.limitMonthlyUsd != null && + limits.limitMonthlyUsd > 0 && + user.limitMonthlyUsd != null && + user.limitMonthlyUsd > 0 && + limits.limitMonthlyUsd > user.limitMonthlyUsd + ) { + return tError("KEY_LIMIT_MONTHLY_EXCEEDS_USER_LIMIT", { + keyLimit: String(limits.limitMonthlyUsd), + userLimit: String(user.limitMonthlyUsd), + }); + } + + if ( + limits.limitTotalUsd != null && + limits.limitTotalUsd > 0 && + user.limitTotalUsd != null && + user.limitTotalUsd > 0 && + limits.limitTotalUsd > user.limitTotalUsd + ) { + return tError("KEY_LIMIT_TOTAL_EXCEEDS_USER_LIMIT", { + keyLimit: String(limits.limitTotalUsd), + userLimit: String(user.limitTotalUsd), + }); + } + + if ( + limits.limitConcurrentSessions != null && + limits.limitConcurrentSessions > 0 && + user.limitConcurrentSessions != null && + user.limitConcurrentSessions > 0 && + limits.limitConcurrentSessions > user.limitConcurrentSessions + ) { + return tError("KEY_LIMIT_CONCURRENT_EXCEEDS_USER_LIMIT", { + keyLimit: String(limits.limitConcurrentSessions), + userLimit: String(user.limitConcurrentSessions), + }); + } + + return null; +} + +function normalizeTemporaryGroupName(value: string): string { + return value.trim(); +} + +function extractTemporaryKeySequence(name: string): number | null { + const match = name.trim().match(/(\d+)$/); + if (!match) return null; + const value = Number(match[1]); + if (!Number.isInteger(value) || value < 0) return null; + return value; +} + +function resolveNextTemporaryKeySequence(existingKeys: Key[], normalizedGroupName: string): number { + let maxSequence = 0; + + for (const key of existingKeys) { + if (key.temporaryGroupName?.trim() !== normalizedGroupName) continue; + const sequence = extractTemporaryKeySequence(key.name); + if (sequence != null && sequence > maxSequence) { + maxSequence = sequence; + } + } + + return maxSequence + 1; +} + +function buildTemporaryKeyName(sequence: number): string { + return String(sequence).padStart(3, "0"); +} + +function buildTemporaryKeyGroupText(keys: Array<{ key: string }>): string { + return keys.map((key) => key.key).join("\n"); +} + export interface BatchUpdateKeysParams { keyIds: number[]; updates: { @@ -828,6 +966,272 @@ export async function getKeysWithStatistics( } } +export interface CreateTemporaryKeysBatchParams { + userId: number; + baseKeyId: number; + count: number; + customLimitTotalUsd?: number; +} + +export interface TemporaryKeyBatchItem { + name: string; + key: string; + createdAt: string; + expiresAt: string | null; + limitTotalUsd: number | null; +} + +export interface CreateTemporaryKeysBatchResult { + groupName: string; + createdCount: number; + sourceKeyName: string; + keys: TemporaryKeyBatchItem[]; +} + +export async function createTemporaryKeysBatch( + params: CreateTemporaryKeysBatchParams +): Promise> { + try { + const tError = await getTranslations("errors"); + + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { + ok: false, + error: tError("PERMISSION_DENIED"), + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const count = Number.isFinite(params.count) ? Math.trunc(params.count) : 0; + if (count < 1 || count > 100) { + return { + ok: false, + error: "单次最多生成 100 个临时 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 = normalizeProviderGroup( + user.providerGroup || PROVIDER_GROUP.DEFAULT + ); + if (normalizedGroupName.length > 120) { + return { + ok: false, + error: "临时分组名称不能超过 120 个字符", + 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 normalizedBaseKeyProviderGroup = normalizeProviderGroup( + baseKey.providerGroup || PROVIDER_GROUP.DEFAULT + ); + + 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 expiresAt = baseKey.expiresAt instanceof Date ? baseKey.expiresAt : null; + const existingKeys = await findKeyList(params.userId); + const nextSequence = resolveNextTemporaryKeySequence(existingKeys, normalizedGroupName); + + const createdKeys = await createKeysBatch( + Array.from({ length: count }, (_, index) => ({ + user_id: params.userId, + name: buildTemporaryKeyName(nextSequence + index), + key: `sk-${randomBytes(16).toString("hex")}`, + is_enabled: baseKey.isEnabled, + expires_at: expiresAt, + can_login_web_ui: baseKey.canLoginWebUi, + limit_5h_usd: baseKey.limit5hUsd, + limit_daily_usd: baseKey.limitDailyUsd, + daily_reset_mode: baseKey.dailyResetMode, + daily_reset_time: baseKey.dailyResetTime, + limit_weekly_usd: baseKey.limitWeeklyUsd, + limit_monthly_usd: baseKey.limitMonthlyUsd, + limit_total_usd: + params.customLimitTotalUsd !== undefined + ? params.customLimitTotalUsd + : (baseKey.limitTotalUsd ?? null), + limit_concurrent_sessions: baseKey.limitConcurrentSessions, + // 临时 Key 只追加批量管理能力,不改变原始 Key 的 provider 路由逻辑。 + provider_group: normalizedBaseKeyProviderGroup, + cache_ttl_preference: baseKey.cacheTtlPreference ?? undefined, + temporary_group_name: normalizedGroupName, + })) + ); + + revalidatePath("/dashboard"); + + return { + ok: true, + data: { + groupName: normalizedGroupName, + createdCount: createdKeys.length, + sourceKeyName: baseKey.name, + keys: createdKeys.map((key) => ({ + name: key.name, + key: key.key, + createdAt: key.createdAt.toISOString(), + expiresAt: key.expiresAt?.toISOString() ?? null, + limitTotalUsd: key.limitTotalUsd ?? null, + })), + }, + }; + } catch (error) { + logger.error("批量创建临时 Key 失败:", error); + const message = error instanceof Error ? error.message : "批量创建临时 Key 失败"; + return { ok: false, error: message, errorCode: ERROR_CODES.CREATE_FAILED }; + } +} + +export async function removeTemporaryKeyGroup(params: { + userId: number; + groupName: string; +}): Promise> { + try { + const tError = await getTranslations("errors"); + + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { + ok: false, + error: tError("PERMISSION_DENIED"), + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const normalizedGroupName = normalizeTemporaryGroupName(params.groupName); + if (!normalizedGroupName) { + return { + ok: false, + error: "临时分组名称不能为空", + errorCode: ERROR_CODES.REQUIRED_FIELD, + }; + } + + const userKeys = await findKeyList(params.userId); + const groupKeys = userKeys.filter((key) => key.temporaryGroupName === normalizedGroupName); + if (groupKeys.length === 0) { + return { + ok: false, + error: "临时 Key 分组不存在", + errorCode: ERROR_CODES.NOT_FOUND, + }; + } + + 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)); + revalidatePath("/dashboard"); + + return { + ok: true, + data: { + deletedCount, + groupName: normalizedGroupName, + }, + }; + } catch (error) { + logger.error("删除临时 Key 分组失败:", error); + const message = error instanceof Error ? error.message : "删除临时 Key 分组失败"; + return { ok: false, error: message, errorCode: ERROR_CODES.DELETE_FAILED }; + } +} + +export async function downloadTemporaryKeyGroup(params: { + userId: number; + groupName: string; +}): Promise> { + try { + const tError = await getTranslations("errors"); + + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { + ok: false, + error: tError("PERMISSION_DENIED"), + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const normalizedGroupName = normalizeTemporaryGroupName(params.groupName); + if (!normalizedGroupName) { + return { + ok: false, + error: "临时分组名称不能为空", + errorCode: ERROR_CODES.REQUIRED_FIELD, + }; + } + + const userKeys = await findKeyList(params.userId); + const groupKeys = userKeys + .filter((key) => key.temporaryGroupName === normalizedGroupName) + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + + if (groupKeys.length === 0) { + return { + ok: false, + error: "临时 Key 分组不存在", + errorCode: ERROR_CODES.NOT_FOUND, + }; + } + + return { + ok: true, + data: buildTemporaryKeyGroupText(groupKeys), + }; + } catch (error) { + logger.error("下载临时 Key 分组失败:", error); + const message = error instanceof Error ? error.message : "下载临时 Key 分组失败"; + return { ok: false, error: message, errorCode: ERROR_CODES.INTERNAL_ERROR }; + } +} + /** * 获取密钥的限额使用情况(实时数据) */ diff --git a/src/actions/users.ts b/src/actions/users.ts index 821823b91..375dee77b 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -452,6 +452,7 @@ export async function getUsers(params?: GetUsersBatchParams): Promise|,,]+/g, "-").replace(/\s+/g, "-"); +} + +function downloadTextFile(filename: string, content: string) { + const blob = new Blob([content], { type: "text/plain;charset=utf-8;" }); + const url = window.URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + window.URL.revokeObjectURL(url); +} + +function buildKeyTextContent(result: CreateTemporaryKeysBatchResult): string { + return result.keys.map((item) => item.key).join("\n"); +} + +export interface TemporaryKeyBatchDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + user: UserDisplay | null; + onSuccess?: () => void; +} + +export function TemporaryKeyBatchDialog({ + open, + onOpenChange, + user, + onSuccess, +}: TemporaryKeyBatchDialogProps) { + const t = useTranslations("dashboard.userManagement.temporaryKeys"); + const tCommon = useTranslations("common"); + const [isPending, startTransition] = useTransition(); + const [baseKeyId, setBaseKeyId] = useState(""); + const [count, setCount] = useState("5"); + const [customLimitTotalUsd, setCustomLimitTotalUsd] = useState(""); + const [result, setResult] = useState(null); + + const availableKeys = useMemo( + () => user?.keys.filter((key) => !key.temporaryGroupName?.trim()) ?? [], + [user] + ); + + useEffect(() => { + if (!open) return; + setBaseKeyId(availableKeys[0] ? String(availableKeys[0].id) : ""); + setCount("5"); + setCustomLimitTotalUsd(""); + setResult(null); + }, [open, availableKeys]); + + const userTemporaryGroup = useMemo( + () => normalizeProviderGroup(user?.providerGroup || "default"), + [user?.providerGroup] + ); + + const previewText = useMemo(() => { + if (!result) return ""; + return result.keys + .slice(0, 5) + .map((item) => `${item.name}\n${item.key}`) + .join("\n\n"); + }, [result]); + + const handleClose = (nextOpen: boolean) => { + if (isPending) return; + if (!nextOpen) { + setResult(null); + } + onOpenChange(nextOpen); + }; + + const handleDownload = () => { + if (!result) return; + const filename = `${sanitizeFilenameFragment(result.groupName)}-temporary-keys.txt`; + downloadTextFile(filename, buildKeyTextContent(result)); + }; + + const handleSubmit = () => { + if (!user) return; + + startTransition(async () => { + const parsedBaseKeyId = Number(baseKeyId); + const parsedCount = Number(count); + const parsedCustomLimit = + customLimitTotalUsd.trim() === "" ? undefined : Number(customLimitTotalUsd); + + if (!Number.isInteger(parsedBaseKeyId) || parsedBaseKeyId <= 0) { + toast.error(t("createDialog.baseKeyRequired")); + return; + } + + if (!Number.isFinite(parsedCount) || parsedCount <= 0) { + toast.error(t("createDialog.invalidCount")); + return; + } + + if ( + parsedCustomLimit !== undefined && + (!Number.isFinite(parsedCustomLimit) || parsedCustomLimit < 0) + ) { + toast.error(t("createDialog.invalidLimit")); + return; + } + + const response = await createTemporaryKeysBatch({ + userId: user.id, + baseKeyId: parsedBaseKeyId, + count: parsedCount, + customLimitTotalUsd: parsedCustomLimit, + }); + + if (!response.ok) { + toast.error( + t("toasts.createFailed", { + error: response.error || t("createDialog.genericError"), + }) + ); + return; + } + + setResult(response.data); + onSuccess?.(); + toast.success(t("toasts.createSuccess", { count: response.data.createdCount })); + }); + }; + + return ( + + + {result ? ( + <> + + {t("success.title")} + + {t("success.description", { + group: result.groupName, + count: result.createdCount, + })} + + + +
+
+
+ + +
+
+ + +
+
+ +
+ +