From 3bed7ce8aa54e4d598ba574ec9b2a25fceaa52d0 Mon Sep 17 00:00:00 2001 From: ding113 Date: Sun, 21 Dec 2025 02:23:12 +0800 Subject: [PATCH 01/10] feat: add daily limit feature for user updates - Introduced a new daily limit field in multiple languages for the dashboard. - Updated user management interfaces and components to support daily quota updates. - Enhanced batch edit dialog to include daily limit functionality. This addition improves user control over daily spending limits, aligning with user management needs. --- messages/en/dashboard.json | 1 + messages/ja/dashboard.json | 1 + messages/ru/dashboard.json | 1 + messages/zh-CN/dashboard.json | 1 + messages/zh-TW/dashboard.json | 1 + src/actions/users.ts | 5 +++++ .../user/batch-edit/batch-edit-dialog.tsx | 8 ++++++++ .../user/batch-edit/batch-user-section.tsx | 19 +++++++++++++++++++ 8 files changed, 37 insertions(+) diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index e93777469..fed703d86 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -1120,6 +1120,7 @@ "note": "Note", "tags": "Tags", "limit5h": "5h Limit (USD)", + "limitDaily": "Daily Limit (USD)", "limitWeekly": "Weekly Limit (USD)", "limitMonthly": "Monthly Limit (USD)" }, diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index e339cd2a3..b7e7b5c65 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -1081,6 +1081,7 @@ "note": "メモ", "tags": "タグ", "limit5h": "5時間上限 (USD)", + "limitDaily": "日次上限 (USD)", "limitWeekly": "週間上限 (USD)", "limitMonthly": "月間上限 (USD)" }, diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 1b0c4eca2..accb0c990 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -1092,6 +1092,7 @@ "note": "Заметка", "tags": "Теги", "limit5h": "Лимит за 5 часов (USD)", + "limitDaily": "Дневной лимит (USD)", "limitWeekly": "Недельный лимит (USD)", "limitMonthly": "Месячный лимит (USD)" }, diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 93c320ba7..77c6e9b28 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -1186,6 +1186,7 @@ "note": "备注", "tags": "标签", "limit5h": "5h 限额 (USD)", + "limitDaily": "每日限额 (USD)", "limitWeekly": "周限额 (USD)", "limitMonthly": "月限额 (USD)" }, diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index ed83b3c65..2159f34df 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -1093,6 +1093,7 @@ "note": "備註", "tags": "標籤", "limit5h": "5h 限額 (USD)", + "limitDaily": "每日限額 (USD)", "limitWeekly": "週限額 (USD)", "limitMonthly": "月限額 (USD)" }, diff --git a/src/actions/users.ts b/src/actions/users.ts index 8d26a44fd..b4e37613f 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -57,6 +57,7 @@ export interface BatchUpdateUsersParams { updates: { note?: string; tags?: string[]; + dailyQuota?: number | null; limit5hUsd?: number | null; limitWeeklyUsd?: number | null; limitMonthlyUsd?: number | null; @@ -485,6 +486,7 @@ export async function batchUpdateUsers( const updatesSchema = UpdateUserSchema.pick({ note: true, tags: true, + dailyQuota: true, limit5hUsd: true, limitWeeklyUsd: true, limitMonthlyUsd: true, @@ -526,6 +528,9 @@ export async function batchUpdateUsers( if (updates.note !== undefined) dbUpdates.description = updates.note; if (updates.tags !== undefined) dbUpdates.tags = updates.tags; + if (updates.dailyQuota !== undefined) + dbUpdates.dailyLimitUsd = + updates.dailyQuota === null ? null : updates.dailyQuota.toString(); if (updates.limit5hUsd !== undefined) dbUpdates.limit5hUsd = updates.limit5hUsd === null ? null : updates.limit5hUsd.toString(); if (updates.limitWeeklyUsd !== undefined) 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 2a4a9e5ab..78461df0d 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 @@ -47,6 +47,7 @@ type UserFieldLabels = { note: string; tags: string; limit5h: string; + limitDaily: string; limitWeekly: string; limitMonthly: string; }; @@ -68,6 +69,8 @@ const INITIAL_USER_STATE: BatchUserSectionState = { tags: [], limit5hUsdEnabled: false, limit5hUsd: "", + dailyQuotaEnabled: false, + dailyQuota: "", limitWeeklyUsdEnabled: false, limitWeeklyUsd: "", limitMonthlyUsdEnabled: false, @@ -126,6 +129,10 @@ function buildUserUpdates( updates.limit5hUsd = parseNumberOrNull(state.limit5hUsd, args.validationMessages); enabledFields.push(args.fieldLabels.limit5h); } + if (state.dailyQuotaEnabled) { + updates.dailyQuota = parseNumberOrNull(state.dailyQuota, args.validationMessages); + enabledFields.push(args.fieldLabels.limitDaily); + } if (state.limitWeeklyUsdEnabled) { updates.limitWeeklyUsd = parseNumberOrNull(state.limitWeeklyUsd, args.validationMessages); enabledFields.push(args.fieldLabels.limitWeekly); @@ -223,6 +230,7 @@ function BatchEditDialogInner({ note: t("user.fields.note"), tags: t("user.fields.tags"), limit5h: t("user.fields.limit5h"), + limitDaily: t("user.fields.limitDaily"), limitWeekly: t("user.fields.limitWeekly"), limitMonthly: t("user.fields.limitMonthly"), }), 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 cdd74dfe6..65f3698df 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 @@ -12,6 +12,8 @@ export interface BatchUserSectionState { tags: string[]; limit5hUsdEnabled: boolean; limit5hUsd: string; + dailyQuotaEnabled: boolean; + dailyQuota: string; limitWeeklyUsdEnabled: boolean; limitWeeklyUsd: string; limitMonthlyUsdEnabled: boolean; @@ -30,6 +32,7 @@ export interface BatchUserSectionProps { note: string; tags: string; limit5h: string; + limitDaily: string; limitWeekly: string; limitMonthly: string; }; @@ -101,6 +104,22 @@ export function BatchUserSection({ /> + onChange({ dailyQuotaEnabled: enabled })} + enableFieldAria={translations.enableFieldAria} + > + onChange({ dailyQuota: e.target.value })} + disabled={!state.dailyQuotaEnabled} + placeholder={translations.placeholders.emptyNoLimit} + /> + + Date: Sun, 21 Dec 2025 12:17:43 +0800 Subject: [PATCH 02/10] feat: enhance message context and cost tracking with createdAt timestamp - Added createdAt timestamp to the message context in ProxyMessageService for better tracking. - Updated response handler to include requestId and createdAtMs for cost tracking. - Introduced new methods to retrieve cost entries with createdAt for users, providers, and keys, facilitating accurate rolling window calculations. - Enhanced Lua scripts to support optional request_id for better member tracking in Redis. These changes improve the accuracy and traceability of cost-related data in the system. --- src/app/v1/_lib/proxy/message-service.ts | 1 + src/app/v1/_lib/proxy/response-handler.ts | 2 + src/app/v1/_lib/proxy/session.ts | 1 + src/lib/rate-limit/service.ts | 231 +++++++++++------- src/lib/redis/lua-scripts.ts | 34 ++- src/repository/statistics.ts | 119 +++++++++ .../rolling-window-cache-warm.test.ts | 107 ++++++++ 7 files changed, 394 insertions(+), 101 deletions(-) create mode 100644 tests/unit/lib/rate-limit/rolling-window-cache-warm.test.ts diff --git a/src/app/v1/_lib/proxy/message-service.ts b/src/app/v1/_lib/proxy/message-service.ts index 5217ffb53..92e0c420d 100644 --- a/src/app/v1/_lib/proxy/message-service.ts +++ b/src/app/v1/_lib/proxy/message-service.ts @@ -47,6 +47,7 @@ export class ProxyMessageService { session.setMessageContext({ id: messageRequest.id, + createdAt: messageRequest.createdAt, user: authState.user, key: authState.key, apiKey: authState.apiKey, diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index 95eb8e3e8..b02b52b63 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -1791,6 +1791,8 @@ async function trackCostToRedis(session: ProxySession, usage: UsageMetrics | nul keyResetMode: key.dailyResetMode, providerResetTime: provider.dailyResetTime, providerResetMode: provider.dailyResetMode, + requestId: messageContext.id, + createdAtMs: messageContext.createdAt.getTime(), } ); diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 26ceafbd9..021e12cb9 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -19,6 +19,7 @@ export interface AuthState { export interface MessageContext { id: number; + createdAt: Date; user: User; key: Key; apiKey: string; diff --git a/src/lib/rate-limit/service.ts b/src/lib/rate-limit/service.ts index b612238f8..fdc407b9f 100644 --- a/src/lib/rate-limit/service.ts +++ b/src/lib/rate-limit/service.ts @@ -103,6 +103,28 @@ export class RateLimitService { return { normalized, suffix: normalized.replace(":", "") }; } + private static async warmRollingCostZset( + key: string, + entries: Array<{ id: number; createdAt: Date; costUsd: number }>, + ttlSeconds: number + ): Promise { + if (!RateLimitService.redis || RateLimitService.redis.status !== "ready") return; + if (entries.length === 0) return; + + const pipeline = RateLimitService.redis.pipeline(); + + for (const entry of entries) { + const createdAtMs = entry.createdAt.getTime(); + if (!Number.isFinite(createdAtMs)) continue; + if (!Number.isFinite(entry.costUsd) || entry.costUsd <= 0) continue; + + pipeline.zadd(key, createdAtMs, `${createdAtMs}:${entry.id}:${entry.costUsd}`); + } + + pipeline.expire(key, ttlSeconds); + await pipeline.exec(); + } + /** * 检查金额限制(Key、Provider 或 User) * 优先使用 Redis,失败时降级到数据库查询(防止 Redis 清空后超支) @@ -336,8 +358,14 @@ export class RateLimitService { type: "key" | "provider" | "user", costLimits: CostLimit[] ): Promise<{ allowed: boolean; reason?: string }> { - const { sumKeyCostInTimeRange, sumProviderCostInTimeRange, sumUserCostInTimeRange } = - await import("@/repository/statistics"); + const { + findKeyCostEntriesInTimeRange, + findProviderCostEntriesInTimeRange, + findUserCostEntriesInTimeRange, + sumKeyCostInTimeRange, + sumProviderCostInTimeRange, + sumUserCostInTimeRange, + } = await import("@/repository/statistics"); for (const limit of costLimits) { if (!limit.amount || limit.amount <= 0) continue; @@ -350,60 +378,69 @@ export class RateLimitService { ); // 查询数据库 - let current: number; - switch (type) { - case "key": - current = await sumKeyCostInTimeRange(id, startTime, endTime); - break; - case "provider": - current = await sumProviderCostInTimeRange(id, startTime, endTime); - break; - case "user": - current = await sumUserCostInTimeRange(id, startTime, endTime); - break; - default: - current = 0; + let current = 0; + let costEntries: + | Array<{ + id: number; + createdAt: Date; + costUsd: number; + }> + | null = null; + + const isRollingWindow = + limit.period === "5h" || (limit.period === "daily" && limit.resetMode === "rolling"); + + if (isRollingWindow) { + switch (type) { + case "key": + costEntries = await findKeyCostEntriesInTimeRange(id, startTime, endTime); + break; + case "provider": + costEntries = await findProviderCostEntriesInTimeRange(id, startTime, endTime); + break; + case "user": + costEntries = await findUserCostEntriesInTimeRange(id, startTime, endTime); + break; + default: + costEntries = []; + } + + current = costEntries.reduce((sum, row) => sum + row.costUsd, 0); + } else { + switch (type) { + case "key": + current = await sumKeyCostInTimeRange(id, startTime, endTime); + break; + case "provider": + current = await sumProviderCostInTimeRange(id, startTime, endTime); + break; + case "user": + current = await sumUserCostInTimeRange(id, startTime, endTime); + break; + default: + current = 0; + } } // Cache Warming: 写回 Redis if (RateLimitService.redis && RateLimitService.redis.status === "ready") { try { if (limit.period === "5h") { - // 5h 滚动窗口:使用 ZSET + Lua 脚本 - if (current > 0) { - const now = Date.now(); - const window5h = 5 * 60 * 60 * 1000; + // 5h 滚动窗口:Redis 恢复时必须按原始时间戳重建 ZSET,避免窗口边界偏差/重复累计 + if (costEntries && costEntries.length > 0) { const key = `${type}:${id}:cost_5h_rolling`; - - await RateLimitService.redis.eval( - TRACK_COST_5H_ROLLING_WINDOW, - 1, - key, - current.toString(), - now.toString(), - window5h.toString() + await RateLimitService.warmRollingCostZset(key, costEntries, 21600); + logger.info( + `[RateLimit] Cache warmed for ${key}, value=${current} (rolling window, rebuilt)` ); - - logger.info(`[RateLimit] Cache warmed for ${key}, value=${current} (rolling window)`); } } else if (limit.period === "daily" && limit.resetMode === "rolling") { // daily 滚动窗口:使用 ZSET + Lua 脚本 - if (current > 0) { - const now = Date.now(); - const window24h = 24 * 60 * 60 * 1000; + if (costEntries && costEntries.length > 0) { const key = `${type}:${id}:cost_daily_rolling`; - - await RateLimitService.redis.eval( - TRACK_COST_DAILY_ROLLING_WINDOW, - 1, - key, - current.toString(), - now.toString(), - window24h.toString() - ); - + await RateLimitService.warmRollingCostZset(key, costEntries, 90000); logger.info( - `[RateLimit] Cache warmed for ${key}, value=${current} (daily rolling window)` + `[RateLimit] Cache warmed for ${key}, value=${current} (daily rolling window, rebuilt)` ); } } else { @@ -548,6 +585,8 @@ export class RateLimitService { keyResetMode?: DailyResetMode; providerResetTime?: string; providerResetMode?: DailyResetMode; + requestId?: number; + createdAtMs?: number; } ): Promise { if (!RateLimitService.redis || cost <= 0) return; @@ -557,7 +596,8 @@ export class RateLimitService { const providerDailyReset = RateLimitService.resolveDailyReset(options?.providerResetTime); const keyDailyMode = options?.keyResetMode ?? "fixed"; const providerDailyMode = options?.providerResetMode ?? "fixed"; - const now = Date.now(); + const now = options?.createdAtMs ?? Date.now(); + const requestId = options?.requestId != null ? String(options.requestId) : ""; const window5h = 5 * 60 * 60 * 1000; // 5 hours in ms const window24h = 24 * 60 * 60 * 1000; // 24 hours in ms @@ -579,7 +619,8 @@ export class RateLimitService { `key:${keyId}:cost_5h_rolling`, // KEYS[1] cost.toString(), // ARGV[1]: cost now.toString(), // ARGV[2]: now - window5h.toString() // ARGV[3]: window + window5h.toString(), // ARGV[3]: window + requestId // ARGV[4]: request_id (optional) ); // Provider 的 5h 滚动窗口 @@ -589,7 +630,8 @@ export class RateLimitService { `provider:${providerId}:cost_5h_rolling`, cost.toString(), now.toString(), - window5h.toString() + window5h.toString(), + requestId ); // 2. daily 滚动窗口:使用 Lua 脚本(ZSET) @@ -600,7 +642,8 @@ export class RateLimitService { `key:${keyId}:cost_daily_rolling`, cost.toString(), now.toString(), - window24h.toString() + window24h.toString(), + requestId ); } @@ -611,7 +654,8 @@ export class RateLimitService { `provider:${providerId}:cost_daily_rolling`, cost.toString(), now.toString(), - window24h.toString() + window24h.toString(), + requestId ); } @@ -749,9 +793,12 @@ export class RateLimitService { } // Slow Path: 数据库查询 - const { sumKeyCostInTimeRange, sumProviderCostInTimeRange } = await import( - "@/repository/statistics" - ); + const { + findKeyCostEntriesInTimeRange, + findProviderCostEntriesInTimeRange, + sumKeyCostInTimeRange, + sumProviderCostInTimeRange, + } = await import("@/repository/statistics"); const { startTime, endTime } = getTimeRangeForPeriodWithMode( period, @@ -759,59 +806,61 @@ export class RateLimitService { resetMode ); - let current: number; - switch (type) { - case "key": - current = await sumKeyCostInTimeRange(id, startTime, endTime); - break; - case "provider": - current = await sumProviderCostInTimeRange(id, startTime, endTime); - break; - default: - current = 0; + let current = 0; + let costEntries: + | Array<{ + id: number; + createdAt: Date; + costUsd: number; + }> + | null = null; + + const isRollingWindow = period === "5h" || (period === "daily" && resetMode === "rolling"); + + if (isRollingWindow) { + switch (type) { + case "key": + costEntries = await findKeyCostEntriesInTimeRange(id, startTime, endTime); + break; + case "provider": + costEntries = await findProviderCostEntriesInTimeRange(id, startTime, endTime); + break; + default: + costEntries = []; + } + + current = costEntries.reduce((sum, row) => sum + row.costUsd, 0); + } else { + switch (type) { + case "key": + current = await sumKeyCostInTimeRange(id, startTime, endTime); + break; + case "provider": + current = await sumProviderCostInTimeRange(id, startTime, endTime); + break; + default: + current = 0; + } } // Cache Warming: 写回 Redis if (RateLimitService.redis && RateLimitService.redis.status === "ready") { try { if (period === "5h") { - // 5h 滚动窗口:需要将历史数据转换为 ZSET 格式 - // 由于无法精确知道每次消费的时间戳,使用当前时间作为近似 - if (current > 0) { - const now = Date.now(); - const window5h = 5 * 60 * 60 * 1000; + if (costEntries && costEntries.length > 0) { const key = `${type}:${id}:cost_5h_rolling`; - - // 将数据库查询到的总额作为单条记录写入 - await RateLimitService.redis.eval( - TRACK_COST_5H_ROLLING_WINDOW, - 1, - key, - current.toString(), - now.toString(), - window5h.toString() + await RateLimitService.warmRollingCostZset(key, costEntries, 21600); + logger.info( + `[RateLimit] Cache warmed for ${key}, value=${current} (rolling window, rebuilt)` ); - - logger.info(`[RateLimit] Cache warmed for ${key}, value=${current} (rolling window)`); } } else if (period === "daily" && resetMode === "rolling") { // daily 滚动窗口:使用 ZSET + Lua 脚本 - if (current > 0) { - const now = Date.now(); - const window24h = 24 * 60 * 60 * 1000; + if (costEntries && costEntries.length > 0) { const key = `${type}:${id}:cost_daily_rolling`; - - await RateLimitService.redis.eval( - TRACK_COST_DAILY_ROLLING_WINDOW, - 1, - key, - current.toString(), - now.toString(), - window24h.toString() - ); - + await RateLimitService.warmRollingCostZset(key, costEntries, 90000); logger.info( - `[RateLimit] Cache warmed for ${key}, value=${current} (daily rolling window)` + `[RateLimit] Cache warmed for ${key}, value=${current} (daily rolling window, rebuilt)` ); } } else { diff --git a/src/lib/redis/lua-scripts.ts b/src/lib/redis/lua-scripts.ts index 530132e31..0980a090a 100644 --- a/src/lib/redis/lua-scripts.ts +++ b/src/lib/redis/lua-scripts.ts @@ -114,6 +114,7 @@ return results * ARGV[1]: cost(本次消费金额) * ARGV[2]: now(当前时间戳,毫秒) * ARGV[3]: window(窗口时长,毫秒,默认 18000000 = 5小时) + * ARGV[4]: request_id(可选,用于 member 去重) * * 返回值:string - 当前窗口内的总消费 */ @@ -122,20 +123,26 @@ local key = KEYS[1] local cost = tonumber(ARGV[1]) local now_ms = tonumber(ARGV[2]) local window_ms = tonumber(ARGV[3]) -- 5 hours = 18000000 ms +local request_id = ARGV[4] -- 1. 清理过期记录(5 小时前的数据) redis.call('ZREMRANGEBYSCORE', key, '-inf', now_ms - window_ms) --- 2. 添加当前消费记录(member = timestamp:cost,便于调试和追踪) -local member = now_ms .. ':' .. cost +-- 2. 添加当前消费记录(member = timestamp:cost 或 timestamp:requestId:cost,便于调试和追踪) +local member +if request_id and request_id ~= '' then + member = now_ms .. ':' .. request_id .. ':' .. cost +else + member = now_ms .. ':' .. cost +end redis.call('ZADD', key, now_ms, member) -- 3. 计算窗口内总消费 local records = redis.call('ZRANGE', key, 0, -1) local total = 0 for _, record in ipairs(records) do - -- 解析 member 格式:"timestamp:cost" - local cost_str = string.match(record, ':(.+)') + -- 解析 member 格式:"timestamp:cost" 或 "timestamp:id:cost" + local cost_str = string.match(record, '.*:(.+)') if cost_str then total = total + tonumber(cost_str) end @@ -172,7 +179,7 @@ redis.call('ZREMRANGEBYSCORE', key, '-inf', now_ms - window_ms) local records = redis.call('ZRANGE', key, 0, -1) local total = 0 for _, record in ipairs(records) do - local cost_str = string.match(record, ':(.+)') + local cost_str = string.match(record, '.*:(.+)') if cost_str then total = total + tonumber(cost_str) end @@ -194,6 +201,7 @@ return tostring(total) * ARGV[1]: cost(本次消费金额) * ARGV[2]: now(当前时间戳,毫秒) * ARGV[3]: window(窗口时长,毫秒,默认 86400000 = 24小时) + * ARGV[4]: request_id(可选,用于 member 去重) * * 返回值:string - 当前窗口内的总消费 */ @@ -202,20 +210,26 @@ local key = KEYS[1] local cost = tonumber(ARGV[1]) local now_ms = tonumber(ARGV[2]) local window_ms = tonumber(ARGV[3]) -- 24 hours = 86400000 ms +local request_id = ARGV[4] -- 1. 清理过期记录(24 小时前的数据) redis.call('ZREMRANGEBYSCORE', key, '-inf', now_ms - window_ms) --- 2. 添加当前消费记录(member = timestamp:cost,便于调试和追踪) -local member = now_ms .. ':' .. cost +-- 2. 添加当前消费记录(member = timestamp:cost 或 timestamp:requestId:cost,便于调试和追踪) +local member +if request_id and request_id ~= '' then + member = now_ms .. ':' .. request_id .. ':' .. cost +else + member = now_ms .. ':' .. cost +end redis.call('ZADD', key, now_ms, member) -- 3. 计算窗口内总消费 local records = redis.call('ZRANGE', key, 0, -1) local total = 0 for _, record in ipairs(records) do - -- 解析 member 格式:"timestamp:cost" - local cost_str = string.match(record, ':(.+)') + -- 解析 member 格式:"timestamp:cost" 或 "timestamp:id:cost" + local cost_str = string.match(record, '.*:(.+)') if cost_str then total = total + tonumber(cost_str) end @@ -252,7 +266,7 @@ redis.call('ZREMRANGEBYSCORE', key, '-inf', now_ms - window_ms) local records = redis.call('ZRANGE', key, 0, -1) local total = 0 for _, record in ipairs(records) do - local cost_str = string.match(record, ':(.+)') + local cost_str = string.match(record, '.*:(.+)') if cost_str then total = total + tonumber(cost_str) end diff --git a/src/repository/statistics.ts b/src/repository/statistics.ts index b41499ac2..deb6d75df 100644 --- a/src/repository/statistics.ts +++ b/src/repository/statistics.ts @@ -868,6 +868,125 @@ export async function sumKeyCostInTimeRange( return Number(result[0]?.total || 0); } +export interface CostEntryInTimeRange { + id: number; + createdAt: Date; + costUsd: number; +} + +/** + * 查询用户在指定时间范围内的消费明细(用于滚动窗口 Redis 恢复) + */ +export async function findUserCostEntriesInTimeRange( + userId: number, + startTime: Date, + endTime: Date +): Promise { + const rows = await db + .select({ + id: messageRequest.id, + createdAt: messageRequest.createdAt, + costUsd: messageRequest.costUsd, + }) + .from(messageRequest) + .where( + and( + eq(messageRequest.userId, userId), + gte(messageRequest.createdAt, startTime), + lt(messageRequest.createdAt, endTime), + isNull(messageRequest.deletedAt) + ) + ); + + return rows + .map((row) => { + if (!row.createdAt) return null; + const costUsd = Number(row.costUsd || 0); + if (!Number.isFinite(costUsd) || costUsd <= 0) return null; + return { id: row.id, createdAt: row.createdAt, costUsd }; + }) + .filter((row): row is CostEntryInTimeRange => row !== null); +} + +/** + * 查询供应商在指定时间范围内的消费明细(用于滚动窗口 Redis 恢复) + */ +export async function findProviderCostEntriesInTimeRange( + providerId: number, + startTime: Date, + endTime: Date +): Promise { + const rows = await db + .select({ + id: messageRequest.id, + createdAt: messageRequest.createdAt, + costUsd: messageRequest.costUsd, + }) + .from(messageRequest) + .where( + and( + eq(messageRequest.providerId, providerId), + gte(messageRequest.createdAt, startTime), + lt(messageRequest.createdAt, endTime), + isNull(messageRequest.deletedAt) + ) + ); + + return rows + .map((row) => { + if (!row.createdAt) return null; + const costUsd = Number(row.costUsd || 0); + if (!Number.isFinite(costUsd) || costUsd <= 0) return null; + return { id: row.id, createdAt: row.createdAt, costUsd }; + }) + .filter((row): row is CostEntryInTimeRange => row !== null); +} + +/** + * 查询 Key 在指定时间范围内的消费明细(用于滚动窗口 Redis 恢复) + */ +export async function findKeyCostEntriesInTimeRange( + keyId: number, + startTime: Date, + endTime: Date +): Promise { + // 注意:message_request.key 存储的是 API key 字符串,需要先查询 keys 表获取 key 值 + const keyRecord = await db + .select({ key: keys.key }) + .from(keys) + .where(eq(keys.id, keyId)) + .limit(1); + + if (!keyRecord || keyRecord.length === 0) return []; + + const keyString = keyRecord[0].key; + + const rows = await db + .select({ + id: messageRequest.id, + createdAt: messageRequest.createdAt, + costUsd: messageRequest.costUsd, + }) + .from(messageRequest) + .where( + and( + eq(messageRequest.key, keyString), // 使用 key 字符串而非 ID + gte(messageRequest.createdAt, startTime), + lt(messageRequest.createdAt, endTime), + isNull(messageRequest.deletedAt) + ) + ); + + return rows + .map((row) => { + if (!row.createdAt) return null; + const costUsd = Number(row.costUsd || 0); + if (!Number.isFinite(costUsd) || costUsd <= 0) return null; + return { id: row.id, createdAt: row.createdAt, costUsd }; + }) + .filter((row): row is CostEntryInTimeRange => row !== null); +} + /** * 获取限流事件统计数据 * 查询 message_request 表中包含 rate_limit_metadata 的错误记录 diff --git a/tests/unit/lib/rate-limit/rolling-window-cache-warm.test.ts b/tests/unit/lib/rate-limit/rolling-window-cache-warm.test.ts new file mode 100644 index 000000000..e5c6f761a --- /dev/null +++ b/tests/unit/lib/rate-limit/rolling-window-cache-warm.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const pipelineCommands: Array = []; + +const pipeline = { + zadd: vi.fn((...args: unknown[]) => { + pipelineCommands.push(["zadd", ...args]); + return pipeline; + }), + expire: vi.fn((...args: unknown[]) => { + pipelineCommands.push(["expire", ...args]); + return pipeline; + }), + incrbyfloat: vi.fn(() => pipeline), + exec: vi.fn(async () => { + pipelineCommands.push(["exec"]); + return []; + }), +}; + +const redisClient = { + status: "ready", + eval: vi.fn(async () => "0"), + exists: vi.fn(async () => 0), + pipeline: vi.fn(() => pipeline), + get: vi.fn(async () => null), + set: vi.fn(async () => "OK"), +}; + +vi.mock("@/lib/redis", () => ({ + getRedisClient: () => redisClient, +})); + +const statisticsMock = { + sumKeyTotalCost: vi.fn(async () => 0), + sumUserCostToday: vi.fn(async () => 0), + sumUserTotalCost: vi.fn(async () => 0), + sumKeyCostInTimeRange: vi.fn(async () => 0), + sumProviderCostInTimeRange: vi.fn(async () => 0), + sumUserCostInTimeRange: vi.fn(async () => 0), + findKeyCostEntriesInTimeRange: vi.fn(async () => []), + findProviderCostEntriesInTimeRange: vi.fn(async () => []), + findUserCostEntriesInTimeRange: vi.fn(async () => []), +}; + +vi.mock("@/repository/statistics", () => statisticsMock); + +describe("RateLimitService rolling window cache warm", () => { + const nowMs = 1_700_000_000_000; + + beforeEach(() => { + pipelineCommands.length = 0; + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date(nowMs)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("getCurrentCost(5h) rebuilds ZSET from DB entries on cache miss", async () => { + statisticsMock.findKeyCostEntriesInTimeRange.mockResolvedValueOnce([ + { id: 101, createdAt: new Date(nowMs - 4 * 60 * 60 * 1000), costUsd: 1.5 }, + { id: 102, createdAt: new Date(nowMs - 1 * 60 * 60 * 1000), costUsd: 2.0 }, + ]); + + const { RateLimitService } = await import("@/lib/rate-limit"); + + const current = await RateLimitService.getCurrentCost(1, "key", "5h"); + expect(current).toBeCloseTo(3.5, 10); + + const zaddCalls = pipelineCommands.filter((c) => c[0] === "zadd"); + expect(zaddCalls).toHaveLength(2); + + const expireCalls = pipelineCommands.filter((c) => c[0] === "expire"); + expect(expireCalls).toHaveLength(1); + expect(expireCalls[0][1]).toBe("key:1:cost_5h_rolling"); + expect(expireCalls[0][2]).toBe(21600); + + // member format: `${createdAtMs}:${requestId}:${costUsd}` + const first = zaddCalls[0]; + expect(first[1]).toBe("key:1:cost_5h_rolling"); + expect(first[2]).toBe(nowMs - 4 * 60 * 60 * 1000); + expect(first[3]).toBe(`${nowMs - 4 * 60 * 60 * 1000}:101:1.5`); + }); + + it("trackCost passes requestId and uses createdAtMs for rolling windows", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + + await RateLimitService.trackCost(1, 2, "sess", 0.5, { + requestId: 123, + createdAtMs: nowMs - 1000, + keyResetMode: "fixed", + providerResetMode: "fixed", + }); + + const evalCalls = redisClient.eval.mock.calls; + expect(evalCalls.length).toBeGreaterThanOrEqual(2); + + const [firstCall] = evalCalls; + expect(firstCall[2]).toBe("key:1:cost_5h_rolling"); + expect(firstCall[4]).toBe(String(nowMs - 1000)); + expect(firstCall[6]).toBe("123"); + }); +}); + From 07f892480ff0bffc231bcb06d5dfb3ea9ea931f0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 21 Dec 2025 04:18:07 +0000 Subject: [PATCH 03/10] chore: format code (dev-f53e590) --- src/lib/rate-limit/service.ts | 24 ++++++++----------- .../rolling-window-cache-warm.test.ts | 1 - 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/lib/rate-limit/service.ts b/src/lib/rate-limit/service.ts index fdc407b9f..d29d35f67 100644 --- a/src/lib/rate-limit/service.ts +++ b/src/lib/rate-limit/service.ts @@ -379,13 +379,11 @@ export class RateLimitService { // 查询数据库 let current = 0; - let costEntries: - | Array<{ - id: number; - createdAt: Date; - costUsd: number; - }> - | null = null; + let costEntries: Array<{ + id: number; + createdAt: Date; + costUsd: number; + }> | null = null; const isRollingWindow = limit.period === "5h" || (limit.period === "daily" && limit.resetMode === "rolling"); @@ -807,13 +805,11 @@ export class RateLimitService { ); let current = 0; - let costEntries: - | Array<{ - id: number; - createdAt: Date; - costUsd: number; - }> - | null = null; + let costEntries: Array<{ + id: number; + createdAt: Date; + costUsd: number; + }> | null = null; const isRollingWindow = period === "5h" || (period === "daily" && resetMode === "rolling"); diff --git a/tests/unit/lib/rate-limit/rolling-window-cache-warm.test.ts b/tests/unit/lib/rate-limit/rolling-window-cache-warm.test.ts index e5c6f761a..95ffead80 100644 --- a/tests/unit/lib/rate-limit/rolling-window-cache-warm.test.ts +++ b/tests/unit/lib/rate-limit/rolling-window-cache-warm.test.ts @@ -104,4 +104,3 @@ describe("RateLimitService rolling window cache warm", () => { expect(firstCall[6]).toBe("123"); }); }); - From 897158589a064afe45cda164aa32695748597924 Mon Sep 17 00:00:00 2001 From: ding113 Date: Sun, 21 Dec 2025 12:43:23 +0800 Subject: [PATCH 04/10] feat: synchronize initial data in UsageLogsSection and enhance cost tracking in RateLimitService - Added useEffect to sync initialData in UsageLogsSection for better state management. - Updated trackUserDailyCost method to accept optional parameters for requestId and createdAtMs, improving cost tracking accuracy. - Refactored cost calculation logic in RateLimitService to utilize detailed cost entries for rolling window calculations. These changes enhance data consistency and tracking capabilities across the application. --- .../_components/usage-logs-section.tsx | 8 ++++++ src/app/v1/_lib/proxy/response-handler.ts | 6 +++- src/lib/rate-limit/service.ts | 28 +++++++++++++++---- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/app/[locale]/my-usage/_components/usage-logs-section.tsx b/src/app/[locale]/my-usage/_components/usage-logs-section.tsx index b96f5a61e..684072795 100644 --- a/src/app/[locale]/my-usage/_components/usage-logs-section.tsx +++ b/src/app/[locale]/my-usage/_components/usage-logs-section.tsx @@ -57,6 +57,14 @@ export function UsageLogsSection({ const [isPending, startTransition] = useTransition(); const [error, setError] = useState(null); + // Sync initialData from parent when it becomes available + // (useState only uses initialData on first mount, not on subsequent updates) + useEffect(() => { + if (initialData) { + setData(initialData); + } + }, [initialData]); + useEffect(() => { setIsModelsLoading(true); setIsEndpointsLoading(true); diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index b02b52b63..20f43dc64 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -1801,7 +1801,11 @@ async function trackCostToRedis(session: ProxySession, usage: UsageMetrics | nul user.id, costFloat, user.dailyResetTime, - user.dailyResetMode + user.dailyResetMode, + { + requestId: messageContext.id, + createdAtMs: messageContext.createdAt.getTime(), + } ); // 刷新 session 时间戳(滑动窗口) diff --git a/src/lib/rate-limit/service.ts b/src/lib/rate-limit/service.ts index d29d35f67..9489027bd 100644 --- a/src/lib/rate-limit/service.ts +++ b/src/lib/rate-limit/service.ts @@ -988,8 +988,22 @@ export class RateLimitService { logger.info( `[RateLimit] Cache miss for user:${userId}:cost_daily_rolling, querying database` ); - currentCost = await sumUserCostToday(userId); - // Note: Cache Warming 在 rolling 模式下由 trackUserDailyCost 处理 + + // 导入明细查询函数 + const { findUserCostEntriesInTimeRange } = await import("@/repository/statistics"); + + // 计算滚动窗口的时间范围 + const startTime = new Date(now - window24h); + const endTime = new Date(now); + + // 查询明细并计算总和 + const costEntries = await findUserCostEntriesInTimeRange(userId, startTime, endTime); + currentCost = costEntries.reduce((sum, row) => sum + row.costUsd, 0); + + // Cache Warming: 重建 ZSET + if (costEntries.length > 0) { + await RateLimitService.warmRollingCostZset(key, costEntries, 90000); // 25 hours TTL + } } } } else { @@ -1035,12 +1049,14 @@ export class RateLimitService { * 累加用户今日消费(在 trackCost 后调用) * @param resetTime - 重置时间 (HH:mm),仅 fixed 模式使用 * @param resetMode - 重置模式:fixed 或 rolling + * @param options - 可选参数:requestId 和 createdAtMs 用于与 DB 时间轴保持一致 */ static async trackUserDailyCost( userId: number, cost: number, resetTime?: string, - resetMode?: DailyResetMode + resetMode?: DailyResetMode, + options?: { requestId?: number; createdAtMs?: number } ): Promise { if (!RateLimitService.redis || cost <= 0) return; @@ -1051,8 +1067,9 @@ export class RateLimitService { if (mode === "rolling") { // Rolling 模式:使用 ZSET + Lua 脚本 const key = `user:${userId}:cost_daily_rolling`; - const now = Date.now(); + const now = options?.createdAtMs ?? Date.now(); const window24h = 24 * 60 * 60 * 1000; + const requestId = options?.requestId != null ? String(options.requestId) : ""; await RateLimitService.redis.eval( TRACK_COST_DAILY_ROLLING_WINDOW, @@ -1060,7 +1077,8 @@ export class RateLimitService { key, cost.toString(), now.toString(), - window24h.toString() + window24h.toString(), + requestId ); logger.debug(`[RateLimit] Tracked user daily cost (rolling): user=${userId}, cost=${cost}`); From 309020bf1053174f5b9ae5c704dcb5725b58232e Mon Sep 17 00:00:00 2001 From: ding113 Date: Sun, 21 Dec 2025 12:56:31 +0800 Subject: [PATCH 05/10] feat: enhance version management and GitHub integration - Introduced functions to read local version files and fetch the latest release from GitHub, improving version retrieval. - Added error handling for GitHub API requests and fallback mechanisms to ensure version information is consistently available. - Updated the version comparison logic to handle semantic versioning, including support for pre-release identifiers. These changes enhance the application's ability to manage and display version information effectively. --- src/app/api/version/route.ts | 190 +++++++++++++++++++++++++++++------ src/lib/version.ts | 92 +++++++++++++++-- tests/unit/version.test.ts | 34 +++++++ 3 files changed, 275 insertions(+), 41 deletions(-) create mode 100644 tests/unit/version.test.ts diff --git a/src/app/api/version/route.ts b/src/app/api/version/route.ts index 6209b9adc..698509a53 100644 --- a/src/app/api/version/route.ts +++ b/src/app/api/version/route.ts @@ -1,3 +1,5 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; import { NextResponse } from "next/server"; import { logger } from "@/lib/logger"; import { APP_VERSION, compareVersions, GITHUB_REPO } from "@/lib/version"; @@ -5,6 +7,9 @@ import { APP_VERSION, compareVersions, GITHUB_REPO } from "@/lib/version"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; +const REVALIDATE_SECONDS = 5 * 60; // 5 分钟 +const USER_AGENT = "claude-code-hub"; + interface GitHubRelease { tag_name: string; name: string; @@ -12,56 +17,175 @@ interface GitHubRelease { published_at: string; } +interface LatestVersionInfo { + latest: string; + releaseUrl?: string; + publishedAt?: string; +} + +function normalizeVersionForDisplay(version: string): string { + const trimmed = version.trim(); + if (!trimmed) return trimmed; + + // Normalize leading "V" to lowercase. + if (/^v/i.test(trimmed)) { + return `v${trimmed.slice(1)}`; + } + + // Only add "v" prefix for semver-like strings; keep other values (e.g. "dev") as-is. + if (/^\d+(?:\.\d+)*(?:[-+].+)?$/.test(trimmed)) { + return `v${trimmed}`; + } + + return trimmed; +} + +async function readLocalVersionFile(): Promise { + try { + const content = await readFile(join(process.cwd(), "VERSION"), "utf8"); + const trimmed = content.trim(); + return trimmed ? normalizeVersionForDisplay(trimmed) : null; + } catch { + return null; + } +} + +async function getCurrentVersion(): Promise { + const fromEnv = process.env.NEXT_PUBLIC_APP_VERSION?.trim(); + if (fromEnv) return normalizeVersionForDisplay(fromEnv); + + const fromFile = await readLocalVersionFile(); + if (fromFile) return fromFile; + + return normalizeVersionForDisplay(APP_VERSION); +} + +function getGitHubAuthToken(): string | null { + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + return token?.trim() || null; +} + +function buildGitHubHeaders(): Record { + const headers: Record = { + Accept: "application/vnd.github.v3+json", + "User-Agent": USER_AGENT, + }; + + const token = getGitHubAuthToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + return headers; +} + +async function fetchLatestRelease(): Promise { + const response = await fetch( + `https://api.github.com/repos/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/releases/latest`, + { + headers: buildGitHubHeaders(), + next: { + revalidate: REVALIDATE_SECONDS, + }, + } + ); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + throw new Error(`GitHub API 错误: ${response.status}`); + } + + return (await response.json()) as GitHubRelease; +} + +async function fetchLatestVersionFromVersionFile(): Promise { + const response = await fetch( + `https://raw.githubusercontent.com/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/main/VERSION`, + { + headers: { + "User-Agent": USER_AGENT, + }, + next: { + revalidate: REVALIDATE_SECONDS, + }, + } + ); + + if (!response.ok) { + return null; + } + + const version = (await response.text()).trim(); + return version ? normalizeVersionForDisplay(version) : null; +} + +async function getLatestVersionInfo(): Promise { + try { + const release = await fetchLatestRelease(); + if (!release) { + const latest = await fetchLatestVersionFromVersionFile(); + if (!latest) return null; + + return { + latest, + releaseUrl: `https://github.com/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/releases/tag/${latest}`, + }; + } + + return { + latest: normalizeVersionForDisplay(release.tag_name), + releaseUrl: release.html_url, + publishedAt: release.published_at, + }; + } catch (error) { + // Fallback to VERSION file when GitHub API is rate-limited or blocked. + const latest = await fetchLatestVersionFromVersionFile(); + if (!latest) { + throw error; + } + + return { + latest, + releaseUrl: `https://github.com/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/releases/tag/${latest}`, + }; + } +} + /** * GET /api/version * 检查是否有新版本可用 */ export async function GET() { try { - // 获取 GitHub 最新 release - const response = await fetch( - `https://api.github.com/repos/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/releases/latest`, - { - headers: { - Accept: "application/vnd.github.v3+json", - "User-Agent": "claude-code-hub", - }, - next: { - revalidate: 3600, // 缓存 1 小时 - }, - } - ); + const current = await getCurrentVersion(); + const latestInfo = await getLatestVersionInfo(); - if (!response.ok) { - if (response.status === 404) { - return NextResponse.json({ - current: APP_VERSION, - latest: null, - hasUpdate: false, - message: "暂无发布版本", - }); - } - throw new Error(`GitHub API 错误: ${response.status}`); + if (!latestInfo) { + return NextResponse.json({ + current, + latest: null, + hasUpdate: false, + message: "暂无发布版本", + }); } - const release: GitHubRelease = await response.json(); - const latestVersion = release.tag_name; - - // 比较版本 - const hasUpdate = compareVersions(APP_VERSION, latestVersion) === 1; + const hasUpdate = compareVersions(current, latestInfo.latest) === 1; return NextResponse.json({ - current: APP_VERSION, - latest: latestVersion, + current, + latest: latestInfo.latest, hasUpdate, - releaseUrl: release.html_url, - publishedAt: release.published_at, + releaseUrl: latestInfo.releaseUrl, + publishedAt: latestInfo.publishedAt, }); } catch (error) { logger.error("版本检查失败:", error); return NextResponse.json( { - current: APP_VERSION, + current: normalizeVersionForDisplay(APP_VERSION), latest: null, hasUpdate: false, error: "无法获取最新版本信息", diff --git a/src/lib/version.ts b/src/lib/version.ts index e28475ee7..87820facb 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -15,6 +15,47 @@ export const GITHUB_REPO = { repo: "claude-code-hub", }; +type SemverPrereleaseId = + | { kind: "num"; value: number } + | { kind: "str"; value: string }; + +function parseSemverLike( + raw: string +): { numbers: number[]; prerelease: SemverPrereleaseId[] | null } | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + + const withoutPrefix = trimmed.replace(/^v/i, ""); + + // Ignore build metadata. + const withoutBuild = withoutPrefix.split("+")[0] ?? ""; + if (!withoutBuild) return null; + + const [core, prereleaseRaw] = withoutBuild.split("-", 2); + if (!core) return null; + + const numberParts = core.split(".").map((part) => { + const match = part.match(/^\d+/); + if (!match) return Number.NaN; + return Number.parseInt(match[0], 10); + }); + + if (numberParts.some((n) => Number.isNaN(n))) { + return null; + } + + const prerelease = prereleaseRaw + ? prereleaseRaw.split(".").map((id) => { + if (/^\d+$/.test(id)) { + return { kind: "num" as const, value: Number.parseInt(id, 10) }; + } + return { kind: "str" as const, value: id }; + }) + : null; + + return { numbers: numberParts, prerelease }; +} + /** * 比较两个语义化版本号 * @param current 当前版本 (如 "v1.2.3") @@ -31,21 +72,56 @@ export const GITHUB_REPO = { * - isVersionEqual(a, b) - 检查 a 和 b 是否相等 */ export function compareVersions(current: string, latest: string): number { - // 移除 'v' 前缀 - const cleanCurrent = current.replace(/^v/, ""); - const cleanLatest = latest.replace(/^v/, ""); + const currentParsed = parseSemverLike(current); + const latestParsed = parseSemverLike(latest); - const currentParts = cleanCurrent.split(".").map(Number); - const latestParts = cleanLatest.split(".").map(Number); + // Fail open: 任何无法解析的版本都视为相等,避免误判导致拦截/提示异常 + if (!currentParsed || !latestParsed) { + return 0; + } - for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) { - const curr = currentParts[i] || 0; - const lat = latestParts[i] || 0; + // 1) Compare core numbers. + for (let i = 0; i < Math.max(currentParsed.numbers.length, latestParsed.numbers.length); i++) { + const curr = currentParsed.numbers[i] ?? 0; + const lat = latestParsed.numbers[i] ?? 0; if (lat > curr) return 1; if (lat < curr) return -1; } + const currPre = currentParsed.prerelease; + const latPre = latestParsed.prerelease; + + // 2) Core equal: stable > prerelease. + if (!currPre && !latPre) return 0; + if (!currPre && latPre) return -1; + if (currPre && !latPre) return 1; + + // 3) Both prerelease: compare identifiers (SemVer rules). + for (let i = 0; i < Math.max(currPre!.length, latPre!.length); i++) { + const currId = currPre![i]; + const latId = latPre![i]; + + if (!currId && latId) return 1; + if (currId && !latId) return -1; + if (!currId || !latId) return 0; + + if (currId.kind === "num" && latId.kind === "num") { + if (latId.value > currId.value) return 1; + if (latId.value < currId.value) return -1; + continue; + } + + // Numeric identifiers have lower precedence than non-numeric identifiers. + if (currId.kind === "num" && latId.kind === "str") return 1; + if (currId.kind === "str" && latId.kind === "num") return -1; + + if (currId.kind === "str" && latId.kind === "str") { + if (latId.value > currId.value) return 1; + if (latId.value < currId.value) return -1; + } + } + return 0; } diff --git a/tests/unit/version.test.ts b/tests/unit/version.test.ts new file mode 100644 index 000000000..0e0ec1124 --- /dev/null +++ b/tests/unit/version.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "vitest"; +import { compareVersions, isVersionEqual, isVersionGreater, isVersionLess } from "@/lib/version"; + +describe("版本比较", () => { + test("应正确判断是否存在可升级版本(latest > current)", () => { + expect(compareVersions("v0.3.0", "v0.3.33")).toBe(1); + expect(compareVersions("v0.3.33", "v0.3.0")).toBe(-1); + expect(compareVersions("v0.3.33", "v0.3.33")).toBe(0); + }); + + test("应正确处理预发布版本(stable > prerelease)", () => { + expect(compareVersions("v1.2.3-beta.1", "v1.2.3")).toBe(1); + expect(compareVersions("v1.2.3", "v1.2.3-beta.1")).toBe(-1); + }); + + test("应正确比较预发布标识(alpha < beta, alpha.1 < alpha.2)", () => { + expect(compareVersions("v1.2.3-alpha", "v1.2.3-beta")).toBe(1); + expect(compareVersions("v1.2.3-alpha.1", "v1.2.3-alpha.2")).toBe(1); + expect(compareVersions("v1.2.3-alpha.2", "v1.2.3-alpha.10")).toBe(1); + }); + + test("应忽略构建元数据(+build)", () => { + expect(compareVersions("v1.2.3+build.1", "v1.2.3+build.2")).toBe(0); + expect(compareVersions("v1.2.3+build.2", "v1.2.3+build.1")).toBe(0); + }); + + test("无法解析的版本应 Fail Open(视为相等)", () => { + expect(compareVersions("dev", "v1.0.0")).toBe(0); + expect(isVersionLess("dev", "v1.0.0")).toBe(false); + expect(isVersionGreater("dev", "v1.0.0")).toBe(false); + expect(isVersionEqual("dev", "v1.0.0")).toBe(true); + }); +}); + From f2f496cc1b0351039f38b266066b6c237864e509 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 21 Dec 2025 04:56:57 +0000 Subject: [PATCH 06/10] chore: format code (dev-f6923ed) --- src/lib/version.ts | 4 +--- tests/unit/version.test.ts | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/lib/version.ts b/src/lib/version.ts index 87820facb..a1d84c088 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -15,9 +15,7 @@ export const GITHUB_REPO = { repo: "claude-code-hub", }; -type SemverPrereleaseId = - | { kind: "num"; value: number } - | { kind: "str"; value: string }; +type SemverPrereleaseId = { kind: "num"; value: number } | { kind: "str"; value: string }; function parseSemverLike( raw: string diff --git a/tests/unit/version.test.ts b/tests/unit/version.test.ts index 0e0ec1124..b55e3b1d8 100644 --- a/tests/unit/version.test.ts +++ b/tests/unit/version.test.ts @@ -31,4 +31,3 @@ describe("版本比较", () => { expect(isVersionEqual("dev", "v1.0.0")).toBe(true); }); }); - From 50278c4b57db6ae394b514390f5e964b84a892e1 Mon Sep 17 00:00:00 2001 From: ding113 Date: Sun, 21 Dec 2025 13:04:44 +0800 Subject: [PATCH 07/10] feat: enhance version handling and GitHub commit retrieval - Added new interfaces for GitHub commits and enhanced version management functions. - Implemented logic to check for development builds and fetch the latest commit information from GitHub. - Improved the response structure to include commit details and comparison URLs for development versions. These changes improve the application's ability to manage versioning and provide users with up-to-date commit information. --- src/app/api/version/route.ts | 77 +++++++++++++++++++++ src/app/v1/_lib/proxy/rate-limit-guard.ts | 8 ++- tests/unit/api-version-route.test.ts | 81 +++++++++++++++++++++++ 3 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 tests/unit/api-version-route.test.ts diff --git a/src/app/api/version/route.ts b/src/app/api/version/route.ts index 698509a53..49c256c69 100644 --- a/src/app/api/version/route.ts +++ b/src/app/api/version/route.ts @@ -17,6 +17,19 @@ interface GitHubRelease { published_at: string; } +interface GitHubCommit { + sha: string; + html_url: string; + commit?: { + author?: { + date?: string; + }; + committer?: { + date?: string; + }; + }; +} + interface LatestVersionInfo { latest: string; releaseUrl?: string; @@ -40,6 +53,16 @@ function normalizeVersionForDisplay(version: string): string { return trimmed; } +function isDevBuild(version: string): boolean { + return /^dev(?:-|$)/i.test(version.trim()); +} + +function parseDevBuildShortSha(version: string): string | null { + const match = version.trim().match(/^dev-([0-9a-f]{7,40})$/i); + if (!match) return null; + return match[1].slice(0, 7).toLowerCase(); +} + async function readLocalVersionFile(): Promise { try { const content = await readFile(join(process.cwd(), "VERSION"), "utf8"); @@ -101,6 +124,36 @@ async function fetchLatestRelease(): Promise { return (await response.json()) as GitHubRelease; } +async function fetchBranchHeadCommit(branch: string): Promise<{ + shortSha: string; + commitUrl: string; + publishedAt?: string; +}> { + const response = await fetch( + `https://api.github.com/repos/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/commits/${encodeURIComponent(branch)}`, + { + headers: buildGitHubHeaders(), + next: { + revalidate: REVALIDATE_SECONDS, + }, + } + ); + + if (!response.ok) { + throw new Error(`GitHub API 错误: ${response.status}`); + } + + const commit = (await response.json()) as GitHubCommit; + const shortSha = commit.sha.slice(0, 7).toLowerCase(); + const publishedAt = commit.commit?.committer?.date || commit.commit?.author?.date; + + return { + shortSha, + commitUrl: commit.html_url, + publishedAt, + }; +} + async function fetchLatestVersionFromVersionFile(): Promise { const response = await fetch( `https://raw.githubusercontent.com/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/main/VERSION`, @@ -161,6 +214,30 @@ async function getLatestVersionInfo(): Promise { export async function GET() { try { const current = await getCurrentVersion(); + + if (isDevBuild(current)) { + const currentShortSha = parseDevBuildShortSha(current); + const latestCommit = await fetchBranchHeadCommit("dev"); + const latest = `dev-${latestCommit.shortSha}`; + + const hasUpdate = currentShortSha + ? currentShortSha !== latestCommit.shortSha + : current.trim().toLowerCase() !== latest.toLowerCase(); + + const compareUrl = + currentShortSha && hasUpdate + ? `https://github.com/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/compare/${currentShortSha}...${latestCommit.shortSha}` + : latestCommit.commitUrl; + + return NextResponse.json({ + current, + latest, + hasUpdate, + releaseUrl: compareUrl, + publishedAt: latestCommit.publishedAt, + }); + } + const latestInfo = await getLatestVersionInfo(); if (!latestInfo) { diff --git a/src/app/v1/_lib/proxy/rate-limit-guard.ts b/src/app/v1/_lib/proxy/rate-limit-guard.ts index 8085de3d0..ad2f95934 100644 --- a/src/app/v1/_lib/proxy/rate-limit-guard.ts +++ b/src/app/v1/_lib/proxy/rate-limit-guard.ts @@ -238,7 +238,13 @@ export class ProxyRateLimitGuard { } // 7. User 每日额度(User 独有的常用预算) - const dailyCheck = await RateLimitService.checkUserDailyCost(user.id, user.dailyQuota); + const dailyCheck = await RateLimitService.checkUserDailyCost( + user.id, + user.dailyQuota, + user.dailyResetTime, + user.dailyResetMode + ); + if (!dailyCheck.allowed) { logger.warn(`[RateLimit] User daily limit exceeded: user=${user.id}, ${dailyCheck.reason}`); diff --git a/tests/unit/api-version-route.test.ts b/tests/unit/api-version-route.test.ts new file mode 100644 index 000000000..c1d2bbf2e --- /dev/null +++ b/tests/unit/api-version-route.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +function mockJsonResponse(data: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(data), { + status: 200, + headers: { "Content-Type": "application/json" }, + ...init, + }); +} + +describe("/api/version (dev)", () => { + beforeEach(() => { + vi.resetModules(); + process.env.NEXT_PUBLIC_APP_VERSION = "dev-aaaaaaa"; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.NEXT_PUBLIC_APP_VERSION; + delete process.env.GITHUB_TOKEN; + delete process.env.GH_TOKEN; + }); + + test("应在 dev HEAD 不同时提示更新", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/commits/dev")) { + return mockJsonResponse({ + sha: "bbbbbbbcccccccccccccccccccccccccccccccccc", + html_url: "https://github.com/ding113/claude-code-hub/commit/bbbbbbb", + commit: { committer: { date: "2025-12-21T00:00:00Z" } }, + }); + } + throw new Error(`Unexpected fetch: ${url}`); + }) + ); + + const { GET } = await import("@/app/api/version/route"); + const response = await GET(); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data.current).toBe("dev-aaaaaaa"); + expect(data.latest).toBe("dev-bbbbbbb"); + expect(data.hasUpdate).toBe(true); + expect(data.releaseUrl).toContain("/compare/aaaaaaa...bbbbbbb"); + expect(data.publishedAt).toBe("2025-12-21T00:00:00Z"); + }); + + test("应在 dev HEAD 相同时不提示更新", async () => { + process.env.NEXT_PUBLIC_APP_VERSION = "dev-bbbbbbb"; + + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/commits/dev")) { + return mockJsonResponse({ + sha: "bbbbbbbcccccccccccccccccccccccccccccccccc", + html_url: "https://github.com/ding113/claude-code-hub/commit/bbbbbbb", + commit: { committer: { date: "2025-12-21T00:00:00Z" } }, + }); + } + throw new Error(`Unexpected fetch: ${url}`); + }) + ); + + const { GET } = await import("@/app/api/version/route"); + const response = await GET(); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data.current).toBe("dev-bbbbbbb"); + expect(data.latest).toBe("dev-bbbbbbb"); + expect(data.hasUpdate).toBe(false); + expect(data.releaseUrl).toBe("https://github.com/ding113/claude-code-hub/commit/bbbbbbb"); + }); +}); + From 00dcc6b4661d166a2301c1ca4d9883d7787da66b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 21 Dec 2025 05:05:05 +0000 Subject: [PATCH 08/10] chore: format code (dev-812acd8) --- tests/unit/api-version-route.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/api-version-route.test.ts b/tests/unit/api-version-route.test.ts index c1d2bbf2e..78ce1e01a 100644 --- a/tests/unit/api-version-route.test.ts +++ b/tests/unit/api-version-route.test.ts @@ -78,4 +78,3 @@ describe("/api/version (dev)", () => { expect(data.releaseUrl).toBe("https://github.com/ding113/claude-code-hub/commit/bbbbbbb"); }); }); - From f7c0dfbdfc21b519ebc06e3729f018ff165dcde1 Mon Sep 17 00:00:00 2001 From: ding113 Date: Sun, 21 Dec 2025 15:00:13 +0800 Subject: [PATCH 09/10] feat: enhance user provider group handling in KeyRowItem and related components - Added userProviderGroup prop to KeyRowItem for improved group management. - Implemented splitGroups function to handle provider groups more effectively. - Updated UserKeyTableRow and KeyEditSection to utilize userProviderGroup for better data consistency. - Enhanced UI components to display provider groups with tooltips and badges for clarity. - Refactored UsageLogsSection to improve filter management and state handling. These changes enhance the user experience by providing clearer group information and improving data handling across components. --- .../user/forms/key-edit-section.tsx | 40 +++++++++++ .../_components/user/key-row-item.tsx | 68 ++++++++++++++++--- .../_components/user/user-key-table-row.tsx | 1 + .../user/user-management-table.tsx | 57 +++++++++++++--- .../logs/_components/usage-logs-filters.tsx | 2 +- .../_components/virtualized-logs-table.tsx | 65 ++++++++++++++---- src/app/[locale]/dashboard/quotas/page.tsx | 11 +++ .../my-usage/_components/my-usage-header.tsx | 17 ----- .../_components/usage-logs-section.tsx | 66 +++++++++--------- .../my-usage/_components/usage-logs-table.tsx | 8 +-- src/app/[locale]/my-usage/page.tsx | 2 - 11 files changed, 246 insertions(+), 91 deletions(-) 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 65aef51f4..b819329e4 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 @@ -284,6 +284,16 @@ export function KeyEditSection({ () => normalizeGroupList(keyData.providerGroup), [keyData.providerGroup] ); + const keyGroupOptions = useMemo(() => { + if (!normalizedKeyProviderGroup) return []; + return normalizedKeyProviderGroup.split(",").filter(Boolean); + }, [normalizedKeyProviderGroup]); + const extraKeyGroupOption = useMemo(() => { + if (!normalizedKeyProviderGroup) return null; + if (normalizedKeyProviderGroup === normalizedUserProviderGroup) return null; + if (userGroups.includes(normalizedKeyProviderGroup)) return null; + return normalizedKeyProviderGroup; + }, [normalizedKeyProviderGroup, normalizedUserProviderGroup, userGroups]); return (
@@ -424,6 +434,17 @@ export function KeyEditSection({ + {extraKeyGroupOption ? ( + + + {extraKeyGroupOption} + + + ) : null} {userGroups.map((group) => ( @@ -444,6 +465,25 @@ export function KeyEditSection({ : translations.fields.providerGroup.selectHint || "选择此 Key 可使用的供应商分组"}

+ ) : keyGroupOptions.length > 0 ? ( +
+ +
+ {keyGroupOptions.map((group) => ( + + {group} + + ))} +
+

+ {translations.fields.providerGroup.editHint || "已有密钥的分组不可修改"} +

+
) : (
{translations.fields.providerGroup.noGroupHint || "您没有分组限制,可以访问所有供应商"} diff --git a/src/app/[locale]/dashboard/_components/user/key-row-item.tsx b/src/app/[locale]/dashboard/_components/user/key-row-item.tsx index 7eed67581..06099f924 100644 --- a/src/app/[locale]/dashboard/_components/user/key-row-item.tsx +++ b/src/app/[locale]/dashboard/_components/user/key-row-item.tsx @@ -44,6 +44,8 @@ export interface KeyRowItemProps { totalCost: number; }>; }; + /** User-level provider groups (used when key inherits providerGroup). */ + userProviderGroup?: string | null; isMultiSelectMode?: boolean; isSelected?: boolean; onSelect?: (checked: boolean) => void; @@ -85,8 +87,16 @@ export interface KeyRowItemProps { }; } +function splitGroups(value?: string | null): string[] { + return (value ?? "") + .split(",") + .map((g) => g.trim()) + .filter(Boolean); +} + export function KeyRowItem({ keyData, + userProviderGroup, isMultiSelectMode, isSelected, onSelect, @@ -108,9 +118,14 @@ export function KeyRowItem({ const resolvedCurrencyCode: CurrencyCode = currencyCode && currencyCode in CURRENCY_CONFIG ? (currencyCode as CurrencyCode) : "USD"; - const providerGroup = keyData.providerGroup?.trim() - ? keyData.providerGroup - : translations.defaultGroup; + const keyGroups = splitGroups(keyData.providerGroup); + const inheritedGroups = splitGroups(userProviderGroup); + const isInherited = keyGroups.length === 0; + const effectiveGroups = isInherited ? inheritedGroups : keyGroups; + const visibleGroups = effectiveGroups.slice(0, 2); + const remainingGroups = Math.max(0, effectiveGroups.length - visibleGroups.length); + const effectiveGroupText = + effectiveGroups.length > 0 ? effectiveGroups.join(", ") : translations.defaultGroup; const canReveal = Boolean(keyData.fullKey); const canCopy = Boolean(keyData.canCopy && keyData.fullKey); @@ -219,11 +234,48 @@ export function KeyRowItem({ {/* 分组 */}
-
- {translations.fields.group}: - - {providerGroup} - +
+ + {translations.fields.group}: + + + +
+ {visibleGroups.length > 0 ? ( + <> + {visibleGroups.map((group) => ( + + {group} + + ))} + {remainingGroups > 0 ? ( + + +{remainingGroups} + + ) : null} + + ) : ( + + {translations.defaultGroup} + + )} +
+
+ +

+ {effectiveGroupText} +

+
+
diff --git a/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx b/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx index 8612455ca..28c4a6d3c 100644 --- a/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx +++ b/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx @@ -304,6 +304,7 @@ export function UserKeyTableRow({ status: key.status, modelStats: key.modelStats, }} + userProviderGroup={user.providerGroup ?? null} isMultiSelectMode={isMultiSelectMode} isSelected={selectedKeyIds?.has(key.id) ?? false} onSelect={(checked) => onSelectKey?.(key.id, checked)} diff --git a/src/app/[locale]/dashboard/_components/user/user-management-table.tsx b/src/app/[locale]/dashboard/_components/user/user-management-table.tsx index 9b015db15..6e63aa34e 100644 --- a/src/app/[locale]/dashboard/_components/user/user-management-table.tsx +++ b/src/app/[locale]/dashboard/_components/user/user-management-table.tsx @@ -381,17 +381,54 @@ export function UserManagementTable({ GRID_COLUMNS_CLASS )} > -
- {translations.table.columns.username} / {translations.table.columns.note} +
+ + {translations.table.columns.username} / {translations.table.columns.note} + +
+
+ + {translations.table.columns.expiresAt} + +
+
+ + {translations.table.columns.limit5h} + +
+
+ + {translations.table.columns.limitDaily} + +
+
+ + {translations.table.columns.limitWeekly} + +
+
+ + {translations.table.columns.limitMonthly} + +
+
+ + {translations.table.columns.limitTotal} + +
+
+ + {translations.table.columns.limitSessions} + +
+
+ + {translations.actions.edit} +
-
{translations.table.columns.expiresAt}
-
{translations.table.columns.limit5h}
-
{translations.table.columns.limitDaily}
-
{translations.table.columns.limitWeekly}
-
{translations.table.columns.limitMonthly}
-
{translations.table.columns.limitTotal}
-
{translations.table.columns.limitSessions}
-
{translations.actions.edit}
diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx index 7dfc47f59..9848168b7 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx @@ -478,7 +478,7 @@ export function UsageLogsFilters({
{/* 操作按钮 */} -
+