diff --git a/README.md b/README.md index b30faad0..f3faacf8 100644 --- a/README.md +++ b/README.md @@ -946,6 +946,27 @@ proxy_request_buffering off; --- +## ❤️ 赞助支持 + +如果您觉得这个项目对您有帮助,请考虑赞助支持项目的持续开发。您的支持是我们最大的动力! + +
+ + + Sponsor + + + + + + + +
wechatalipay
+ +
+ +--- + ## 📄 许可证 本项目采用 [MIT许可证](LICENSE)。 diff --git a/docs/sponsoring/alipay.jpg b/docs/sponsoring/alipay.jpg new file mode 100644 index 00000000..95e08a8b Binary files /dev/null and b/docs/sponsoring/alipay.jpg differ diff --git a/docs/sponsoring/wechat.jpg b/docs/sponsoring/wechat.jpg new file mode 100644 index 00000000..52543aa6 Binary files /dev/null and b/docs/sponsoring/wechat.jpg differ diff --git a/src/models/redis.js b/src/models/redis.js index 97bf0dec..b39578bb 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -315,14 +315,21 @@ class RedisClient { }) } - // 搜索(apiKey 模式在这里处理,bindingAccount 模式在路由层处理) - if (search && searchMode === 'apiKey') { + // 搜索 + if (search) { const lowerSearch = search.toLowerCase().trim() - filteredKeys = filteredKeys.filter( - (k) => - (k.name && k.name.toLowerCase().includes(lowerSearch)) || - (k.ownerDisplayName && k.ownerDisplayName.toLowerCase().includes(lowerSearch)) - ) + if (searchMode === 'apiKey') { + // apiKey 模式:搜索名称和拥有者 + filteredKeys = filteredKeys.filter( + (k) => + (k.name && k.name.toLowerCase().includes(lowerSearch)) || + (k.ownerDisplayName && k.ownerDisplayName.toLowerCase().includes(lowerSearch)) + ) + } else if (searchMode === 'bindingAccount') { + // bindingAccount 模式:直接在Redis层处理,避免路由层加载10000条 + const accountNameCacheService = require('../services/accountNameCacheService') + filteredKeys = accountNameCacheService.searchByBindingAccount(filteredKeys, lowerSearch) + } } // 4. 排序 diff --git a/src/routes/admin.js b/src/routes/admin.js index 09198d5b..a0b410dc 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -222,57 +222,24 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { // 获取用户服务来补充owner信息 const userService = require('../services/userService') - // 使用优化的分页方法获取数据 - let result = await redis.getApiKeysPaginated({ + // 如果是绑定账号搜索模式,先刷新账户名称缓存 + if (searchMode === 'bindingAccount' && search) { + const accountNameCacheService = require('../services/accountNameCacheService') + await accountNameCacheService.refreshIfNeeded() + } + + // 使用优化的分页方法获取数据(bindingAccount搜索现在在Redis层处理) + const result = await redis.getApiKeysPaginated({ page: pageNum, pageSize: pageSizeNum, searchMode, - search: searchMode === 'apiKey' ? search : '', // apiKey 模式的搜索在 redis 层处理 + search, tag, isActive, sortBy: validSortBy, sortOrder: validSortOrder }) - // 如果是绑定账号搜索模式,需要在这里处理 - if (searchMode === 'bindingAccount' && search) { - const accountNameCacheService = require('../services/accountNameCacheService') - await accountNameCacheService.refreshIfNeeded() - - // 获取所有数据进行绑定账号搜索 - const allResult = await redis.getApiKeysPaginated({ - page: 1, - pageSize: 10000, // 获取所有数据 - searchMode: 'apiKey', - search: '', - tag, - isActive, - sortBy: validSortBy, - sortOrder: validSortOrder - }) - - // 使用缓存服务进行绑定账号搜索 - const filteredKeys = accountNameCacheService.searchByBindingAccount(allResult.items, search) - - // 重新分页 - const total = filteredKeys.length - const totalPages = Math.ceil(total / pageSizeNum) || 1 - const validPage = Math.min(Math.max(1, pageNum), totalPages) - const start = (validPage - 1) * pageSizeNum - const items = filteredKeys.slice(start, start + pageSizeNum) - - result = { - items, - pagination: { - page: validPage, - pageSize: pageSizeNum, - total, - totalPages - }, - availableTags: allResult.availableTags - } - } - // 为每个API Key添加owner的displayName for (const apiKey of result.items) { if (apiKey.userId) { @@ -547,6 +514,10 @@ router.post('/api-keys/batch-stats', authenticateAdmin, async (req, res) => { cacheReadTokens: 0, cost: 0, formattedCost: '$0.00', + dailyCost: 0, + currentWindowCost: 0, + windowRemainingSeconds: null, + allTimeCost: 0, error: error.message } } @@ -732,6 +703,50 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) { const tokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + // 获取实时限制数据 + let dailyCost = 0 + let currentWindowCost = 0 + let windowRemainingSeconds = null + let allTimeCost = 0 + + try { + // 获取当日费用 + dailyCost = await redis.getDailyCost(keyId) + + // 获取历史总费用(用于总费用限制进度条,不受时间范围影响) + const totalCostKey = `usage:cost:total:${keyId}` + allTimeCost = parseFloat((await client.get(totalCostKey)) || '0') + + // 获取 API Key 配置信息以判断是否需要窗口数据 + const apiKey = await redis.getApiKeyById(keyId) + if (apiKey && apiKey.rateLimitWindow > 0) { + const costCountKey = `rate_limit:cost:${keyId}` + const windowStartKey = `rate_limit:window_start:${keyId}` + + currentWindowCost = parseFloat((await client.get(costCountKey)) || '0') + + // 获取窗口开始时间和计算剩余时间 + const windowStart = await client.get(windowStartKey) + if (windowStart) { + const now = Date.now() + const windowStartTime = parseInt(windowStart) + const windowDuration = apiKey.rateLimitWindow * 60 * 1000 // 转换为毫秒 + const windowEndTime = windowStartTime + windowDuration + + // 如果窗口还有效 + if (now < windowEndTime) { + windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000)) + } else { + // 窗口已过期 + windowRemainingSeconds = 0 + currentWindowCost = 0 + } + } + } + } catch (error) { + logger.debug(`获取实时限制数据失败 (key: ${keyId}):`, error.message) + } + return { requests: totalRequests, tokens, @@ -740,10 +755,109 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) { cacheCreateTokens, cacheReadTokens, cost: totalCost, - formattedCost: CostCalculator.formatCost(totalCost) + formattedCost: CostCalculator.formatCost(totalCost), + // 实时限制数据 + dailyCost, + currentWindowCost, + windowRemainingSeconds, + allTimeCost // 历史总费用(用于总费用限制) } } +/** + * 批量获取指定 Keys 的最后使用账号信息 + * POST /admin/api-keys/batch-last-usage + * + * 用于 API Keys 列表页面异步加载最后使用账号数据 + */ +router.post('/api-keys/batch-last-usage', authenticateAdmin, async (req, res) => { + try { + const { keyIds } = req.body + + // 参数验证 + if (!Array.isArray(keyIds) || keyIds.length === 0) { + return res.status(400).json({ + success: false, + error: 'keyIds is required and must be a non-empty array' + }) + } + + // 限制单次最多处理 100 个 Key + if (keyIds.length > 100) { + return res.status(400).json({ + success: false, + error: 'Max 100 keys per request' + }) + } + + logger.debug(`📊 Batch last-usage request: ${keyIds.length} keys`) + + const client = redis.getClientSafe() + const lastUsageData = {} + const accountInfoCache = new Map() + + // 并行获取每个 Key 的最后使用记录 + await Promise.all( + keyIds.map(async (keyId) => { + try { + // 获取最新的使用记录 + const usageRecords = await redis.getUsageRecords(keyId, 1) + if (!Array.isArray(usageRecords) || usageRecords.length === 0) { + lastUsageData[keyId] = null + return + } + + const lastUsageRecord = usageRecords[0] + if (!lastUsageRecord || (!lastUsageRecord.accountId && !lastUsageRecord.accountType)) { + lastUsageData[keyId] = null + return + } + + // 解析账号信息 + const resolvedAccount = await apiKeyService._resolveAccountByUsageRecord( + lastUsageRecord, + accountInfoCache, + client + ) + + if (resolvedAccount) { + lastUsageData[keyId] = { + accountId: resolvedAccount.accountId, + rawAccountId: lastUsageRecord.accountId || resolvedAccount.accountId, + accountType: resolvedAccount.accountType, + accountCategory: resolvedAccount.accountCategory, + accountName: resolvedAccount.accountName, + recordedAt: lastUsageRecord.timestamp || null + } + } else { + // 账号已删除 + lastUsageData[keyId] = { + accountId: null, + rawAccountId: lastUsageRecord.accountId || null, + accountType: 'deleted', + accountCategory: 'deleted', + accountName: '已删除', + recordedAt: lastUsageRecord.timestamp || null + } + } + } catch (error) { + logger.debug(`获取 API Key ${keyId} 的最后使用记录失败:`, error) + lastUsageData[keyId] = null + } + }) + ) + + return res.json({ success: true, data: lastUsageData }) + } catch (error) { + logger.error('❌ Failed to get batch last-usage:', error) + return res.status(500).json({ + success: false, + error: 'Failed to get last-usage data', + message: error.message + }) + } +}) + // 创建新的API Key router.post('/api-keys', authenticateAdmin, async (req, res) => { try { diff --git a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue index a580d628..f699b442 100644 --- a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue @@ -1021,8 +1021,7 @@ onMounted(async () => { } } - // 自动加载账号数据 - await refreshAccounts() + // 使用缓存的账号数据,不自动刷新(用户可点击"刷新账号"按钮手动刷新) }) // 刷新账号列表 diff --git a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue index 0a5591ba..782b52ac 100644 --- a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue @@ -1233,8 +1233,7 @@ onMounted(async () => { } } - // 自动加载账号数据 - await refreshAccounts() + // 使用缓存的账号数据,不自动刷新(用户可点击"刷新账号"按钮手动刷新) form.name = props.apiKey.name @@ -1271,11 +1270,16 @@ onMounted(async () => { form.restrictedModels = props.apiKey.restrictedModels || [] form.allowedClients = props.apiKey.allowedClients || [] form.tags = props.apiKey.tags || [] - // 从后端数据中获取实际的启用状态,而不是根据数组长度推断 - form.enableModelRestriction = props.apiKey.enableModelRestriction || false - form.enableClientRestriction = props.apiKey.enableClientRestriction || false - // 初始化活跃状态,默认为 true - form.isActive = props.apiKey.isActive !== undefined ? props.apiKey.isActive : true + // 从后端数据中获取实际的启用状态,强制转换为布尔值(Redis返回的是字符串) + form.enableModelRestriction = + props.apiKey.enableModelRestriction === true || props.apiKey.enableModelRestriction === 'true' + form.enableClientRestriction = + props.apiKey.enableClientRestriction === true || props.apiKey.enableClientRestriction === 'true' + // 初始化活跃状态,默认为 true(强制转换为布尔值,因为Redis返回字符串) + form.isActive = + props.apiKey.isActive === undefined || + props.apiKey.isActive === true || + props.apiKey.isActive === 'true' // 初始化所有者 form.ownerId = props.apiKey.userId || 'admin' diff --git a/web/admin-spa/src/views/ApiKeysView.vue b/web/admin-spa/src/views/ApiKeysView.vue index 74c5b7b5..d166fb4d 100644 --- a/web/admin-spa/src/views/ApiKeysView.vue +++ b/web/admin-spa/src/views/ApiKeysView.vue @@ -427,84 +427,98 @@
- +
- + 加载中... +
+ +
@@ -544,10 +558,12 @@ - + @@ -567,83 +583,108 @@
- - - - - - - -
0 || + key.totalCostLimit > 0 || + (key.rateLimitWindow > 0 && key.rateLimitCost > 0)) " - class="space-y-1.5" > - +
+
+
+
+ + +
- + @@ -664,10 +705,12 @@ - + @@ -702,8 +745,16 @@ {{ formatLastUsed(key.lastUsedAt) }} 从未使用 + + + 加载中... + + @@ -1272,9 +1323,9 @@