From 2eb50c78c65ab1e29c84dc57a2dacfbdbc9f97a7 Mon Sep 17 00:00:00 2001 From: Kada Liao Date: Sat, 27 Sep 2025 01:12:44 +0800 Subject: [PATCH 01/13] fix: Stringify export data before file write --- scripts/data-transfer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/data-transfer.js b/scripts/data-transfer.js index e6f13f66..dbdbc66e 100644 --- a/scripts/data-transfer.js +++ b/scripts/data-transfer.js @@ -204,7 +204,7 @@ async function exportData() { } // 写入文件 - await fs.writeFile(outputFile, JSON.stringify(exportData, null, 2)) + await fs.writeFile(outputFile, JSON.stringify(exportDataObj, null, 2)) // 显示导出摘要 console.log(`\n${'='.repeat(60)}`) From d1bbc7179660f318e6ff7981253e9aedff47c033 Mon Sep 17 00:00:00 2001 From: yaogdu Date: Sat, 27 Sep 2025 13:36:50 +0800 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20export=20csv=20fro?= =?UTF-8?q?m=20web=20and=20support=20hourly=20TTL=20of=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/data-transfer.js | 221 +++++++++++++++++- src/routes/admin.js | 19 +- src/services/apiKeyService.js | 22 +- .../components/apikeys/CreateApiKeyModal.vue | 69 +++++- .../components/apikeys/ExpiryEditModal.vue | 16 +- .../src/components/apistats/StatsOverview.vue | 4 +- web/admin-spa/src/views/ApiKeysView.vue | 106 ++++++++- 7 files changed, 429 insertions(+), 28 deletions(-) diff --git a/scripts/data-transfer.js b/scripts/data-transfer.js index e6f13f66..29439a4c 100644 --- a/scripts/data-transfer.js +++ b/scripts/data-transfer.js @@ -84,16 +84,214 @@ function sanitizeData(data, type) { return sanitized } +// CSV 字段映射配置 +const CSV_FIELD_MAPPING = { + // 基本信息 + id: 'ID', + name: '名称', + description: '描述', + isActive: '状态', + createdAt: '创建时间', + lastUsedAt: '最后使用时间', + createdBy: '创建者', + + // API Key 信息 + apiKey: 'API密钥', + tokenLimit: '令牌限制', + + // 过期设置 + expirationMode: '过期模式', + expiresAt: '过期时间', + activationDays: '激活天数', + activationUnit: '激活单位', + isActivated: '已激活', + activatedAt: '激活时间', + + // 权限设置 + permissions: '服务权限', + + // 限制设置 + rateLimitWindow: '速率窗口(分钟)', + rateLimitRequests: '请求次数限制', + rateLimitCost: '费用限制(美元)', + concurrencyLimit: '并发限制', + dailyCostLimit: '日费用限制(美元)', + totalCostLimit: '总费用限制(美元)', + weeklyOpusCostLimit: '周Opus费用限制(美元)', + + // 账户绑定 + claudeAccountId: 'Claude专属账户', + claudeConsoleAccountId: 'Claude控制台账户', + geminiAccountId: 'Gemini专属账户', + openaiAccountId: 'OpenAI专属账户', + azureOpenaiAccountId: 'Azure OpenAI专属账户', + bedrockAccountId: 'Bedrock专属账户', + + // 限制配置 + enableModelRestriction: '启用模型限制', + restrictedModels: '限制的模型', + enableClientRestriction: '启用客户端限制', + allowedClients: '允许的客户端', + + // 标签和用户 + tags: '标签', + userId: '用户ID', + userUsername: '用户名', + + // 其他信息 + icon: '图标' +} + +// 数据格式化函数 +function formatCSVValue(key, value, shouldSanitize = false) { + if (!value || value === '' || value === 'null' || value === 'undefined') { + return '' + } + + switch (key) { + case 'apiKey': + if (shouldSanitize && value.length > 10) { + return `${value.substring(0, 10)}...[已脱敏]` + } + return value + + case 'isActive': + case 'isActivated': + case 'enableModelRestriction': + case 'enableClientRestriction': + return value === 'true' ? '是' : '否' + + case 'expirationMode': + return value === 'activation' ? '首次使用后激活' : value === 'fixed' ? '固定时间' : value + + case 'activationUnit': + return value === 'hours' ? '小时' : value === 'days' ? '天' : value + + case 'permissions': + switch (value) { + case 'all': + return '全部服务' + case 'claude': + return '仅Claude' + case 'gemini': + return '仅Gemini' + case 'openai': + return '仅OpenAI' + default: + return value + } + + case 'restrictedModels': + case 'allowedClients': + case 'tags': + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed.join('; ') : value + } catch { + return value + } + + case 'createdAt': + case 'lastUsedAt': + case 'activatedAt': + case 'expiresAt': + if (value) { + try { + return new Date(value).toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + } catch { + return value + } + } + return '' + + case 'rateLimitWindow': + case 'rateLimitRequests': + case 'concurrencyLimit': + case 'activationDays': + case 'tokenLimit': + return value === '0' || value === 0 ? '无限制' : value + + case 'rateLimitCost': + case 'dailyCostLimit': + case 'totalCostLimit': + case 'weeklyOpusCostLimit': + return value === '0' || value === 0 ? '无限制' : `$${value}` + + default: + return value + } +} + +// 转义 CSV 字段 +function escapeCSVField(field) { + if (field === null || field === undefined) { + return '' + } + + const str = String(field) + + // 如果包含逗号、引号或换行符,需要用引号包围 + if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { + // 先转义引号(双引号变成两个双引号) + const escaped = str.replace(/"/g, '""') + return `"${escaped}"` + } + + return str +} + +// 转换数据为 CSV 格式 +function convertToCSV(exportDataObj, shouldSanitize = false) { + if (!exportDataObj.data.apiKeys || exportDataObj.data.apiKeys.length === 0) { + throw new Error('CSV format only supports API Keys export. Please use --types=apikeys') + } + + const { apiKeys } = exportDataObj.data + const fields = Object.keys(CSV_FIELD_MAPPING) + const headers = Object.values(CSV_FIELD_MAPPING) + + // 生成标题行 + const csvLines = [headers.map(escapeCSVField).join(',')] + + // 生成数据行 + for (const apiKey of apiKeys) { + const row = fields.map((field) => { + const value = formatCSVValue(field, apiKey[field], shouldSanitize) + return escapeCSVField(value) + }) + csvLines.push(row.join(',')) + } + + return csvLines.join('\n') +} + // 导出数据 async function exportData() { try { - const outputFile = params.output || `backup-${new Date().toISOString().split('T')[0]}.json` + const format = params.format || 'json' + const fileExtension = format === 'csv' ? '.csv' : '.json' + const defaultFileName = `backup-${new Date().toISOString().split('T')[0]}${fileExtension}` + const outputFile = params.output || defaultFileName const types = params.types ? params.types.split(',') : ['all'] const shouldSanitize = params.sanitize === true + // CSV 格式验证 + if (format === 'csv' && !types.includes('apikeys') && !types.includes('all')) { + logger.error('❌ CSV format only supports API Keys export. Please use --types=apikeys') + process.exit(1) + } + logger.info('🔄 Starting data export...') logger.info(`📁 Output file: ${outputFile}`) logger.info(`📋 Data types: ${types.join(', ')}`) + logger.info(`📄 Output format: ${format.toUpperCase()}`) logger.info(`🔒 Sanitize sensitive data: ${shouldSanitize ? 'YES' : 'NO'}`) // 连接 Redis @@ -203,8 +401,16 @@ async function exportData() { logger.success(`✅ Exported ${admins.length} admins`) } - // 写入文件 - await fs.writeFile(outputFile, JSON.stringify(exportData, null, 2)) + // 根据格式写入文件 + let fileContent + if (format === 'csv') { + fileContent = convertToCSV(exportDataObj, shouldSanitize) + // 添加 UTF-8 BOM 以便 Excel 正确识别中文 + fileContent = `\ufeff${fileContent}` + await fs.writeFile(outputFile, fileContent, 'utf8') + } else { + await fs.writeFile(outputFile, JSON.stringify(exportDataObj, null, 2)) + } // 显示导出摘要 console.log(`\n${'='.repeat(60)}`) @@ -471,8 +677,9 @@ Commands: import Import data from a JSON file to Redis Export Options: - --output=FILE Output filename (default: backup-YYYY-MM-DD.json) + --output=FILE Output filename (default: backup-YYYY-MM-DD.json/.csv) --types=TYPE,... Data types to export: apikeys,accounts,admins,all (default: all) + --format=FORMAT Output format: json,csv (default: json) --sanitize Remove sensitive data from export Import Options: @@ -492,6 +699,12 @@ Examples: # Export specific data types node scripts/data-transfer.js export --types=apikeys,accounts --output=prod-data.json + + # Export API keys to CSV format + node scripts/data-transfer.js export --types=apikeys --format=csv --sanitize + + # Export to CSV with custom filename + node scripts/data-transfer.js export --types=apikeys --format=csv --output=api-keys.csv `) } diff --git a/src/routes/admin.js b/src/routes/admin.js index 88fbff97..947b71de 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -547,6 +547,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { weeklyOpusCostLimit, tags, activationDays, // 新增:激活后有效天数 + activationUnit, // 新增:激活时间单位 (hours/days) expirationMode, // 新增:过期模式 icon // 新增:图标 } = req.body @@ -643,14 +644,23 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { } if (expirationMode === 'activation') { + // 验证激活时间单位 + if (!activationUnit || !['hours', 'days'].includes(activationUnit)) { + return res.status(400).json({ + error: 'Activation unit must be either "hours" or "days" when using activation mode' + }) + } + + // 验证激活时间数值 if ( !activationDays || !Number.isInteger(Number(activationDays)) || Number(activationDays) < 1 ) { - return res - .status(400) - .json({ error: 'Activation days must be a positive integer when using activation mode' }) + const unitText = activationUnit === 'hours' ? 'hours' : 'days' + return res.status(400).json({ + error: `Activation ${unitText} must be a positive integer when using activation mode` + }) } // 激活模式下不应该设置固定过期时间 if (expiresAt) { @@ -684,6 +694,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { weeklyOpusCostLimit, tags, activationDays, + activationUnit, expirationMode, icon }) @@ -724,6 +735,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { weeklyOpusCostLimit, tags, activationDays, + activationUnit, expirationMode, icon } = req.body @@ -774,6 +786,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { weeklyOpusCostLimit, tags, activationDays, + activationUnit, expirationMode, icon }) diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index 3674a96e..9d0a94dd 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -37,6 +37,7 @@ class ApiKeyService { weeklyOpusCostLimit = 0, tags = [], activationDays = 0, // 新增:激活后有效天数(0表示不使用此功能) + activationUnit = 'days', // 新增:激活时间单位 'hours' 或 'days' expirationMode = 'fixed', // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活) icon = '' // 新增:图标(base64编码) } = options @@ -73,6 +74,7 @@ class ApiKeyService { weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0), tags: JSON.stringify(tags || []), activationDays: String(activationDays || 0), // 新增:激活后有效天数 + activationUnit: activationUnit || 'days', // 新增:激活时间单位 expirationMode: expirationMode || 'fixed', // 新增:过期模式 isActivated: expirationMode === 'fixed' ? 'true' : 'false', // 根据模式决定激活状态 activatedAt: expirationMode === 'fixed' ? new Date().toISOString() : '', // 激活时间 @@ -117,6 +119,7 @@ class ApiKeyService { weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0), tags: JSON.parse(keyData.tags || '[]'), activationDays: parseInt(keyData.activationDays || 0), + activationUnit: keyData.activationUnit || 'days', expirationMode: keyData.expirationMode || 'fixed', isActivated: keyData.isActivated === 'true', activatedAt: keyData.activatedAt, @@ -152,8 +155,18 @@ class ApiKeyService { if (keyData.expirationMode === 'activation' && keyData.isActivated !== 'true') { // 首次使用,需要激活 const now = new Date() - const activationDays = parseInt(keyData.activationDays || 30) // 默认30天 - const expiresAt = new Date(now.getTime() + activationDays * 24 * 60 * 60 * 1000) + const activationPeriod = parseInt(keyData.activationDays || 30) // 默认30 + const activationUnit = keyData.activationUnit || 'days' // 默认天 + + // 根据单位计算过期时间 + let milliseconds + if (activationUnit === 'hours') { + milliseconds = activationPeriod * 60 * 60 * 1000 // 小时转毫秒 + } else { + milliseconds = activationPeriod * 24 * 60 * 60 * 1000 // 天转毫秒 + } + + const expiresAt = new Date(now.getTime() + milliseconds) // 更新激活状态和过期时间 keyData.isActivated = 'true' @@ -167,7 +180,7 @@ class ApiKeyService { logger.success( `🔓 API key activated: ${keyData.id} (${ keyData.name - }), will expire in ${activationDays} days at ${expiresAt.toISOString()}` + }), will expire in ${activationPeriod} ${activationUnit} at ${expiresAt.toISOString()}` ) } @@ -361,6 +374,7 @@ class ApiKeyService { expirationMode: keyData.expirationMode || 'fixed', isActivated: keyData.isActivated === 'true', activationDays: parseInt(keyData.activationDays || 0), + activationUnit: keyData.activationUnit || 'days', activatedAt: keyData.activatedAt || null, claudeAccountId: keyData.claudeAccountId, claudeConsoleAccountId: keyData.claudeConsoleAccountId, @@ -432,6 +446,7 @@ class ApiKeyService { key.dailyCost = (await redis.getDailyCost(key.id)) || 0 key.weeklyOpusCost = (await redis.getWeeklyOpusCost(key.id)) || 0 key.activationDays = parseInt(key.activationDays || 0) + key.activationUnit = key.activationUnit || 'days' key.expirationMode = key.expirationMode || 'fixed' key.isActivated = key.isActivated === 'true' key.activatedAt = key.activatedAt || null @@ -541,6 +556,7 @@ class ApiKeyService { 'permissions', 'expiresAt', 'activationDays', // 新增:激活后有效天数 + 'activationUnit', // 新增:激活时间单位 'expirationMode', // 新增:过期模式 'isActivated', // 新增:是否已激活 'activatedAt', // 新增:激活时间 diff --git a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue index c3ca64c0..75c4d82c 100644 --- a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue @@ -492,11 +492,11 @@

- 固定时间模式:Key 创建后立即生效,按设定时间过期 + 固定时间模式:Key 创建后立即生效,按设定时间过期(支持小时和天数) - 激活模式:Key 首次使用时激活,激活后按设定天数过期(适合批量销售) + 激活模式:Key 首次使用时激活,激活后按设定时间过期(支持小时和天数,适合批量销售)

@@ -509,6 +509,10 @@ @change="updateExpireAt" > + + + + @@ -537,27 +541,36 @@ - +

- Key 将在首次使用后激活,激活后 {{ form.activationDays || 30 }} 天过期 + Key 将在首次使用后激活,激活后 + {{ form.activationDays || (form.activationUnit === 'hours' ? 24 : 30) }} + {{ form.activationUnit === 'hours' ? '小时' : '天' }}过期

@@ -917,6 +930,7 @@ const form = reactive({ expiresAt: null, expirationMode: 'fixed', // 过期模式:fixed(固定) 或 activation(激活) activationDays: 30, // 激活后有效天数 + activationUnit: 'days', // 激活时间单位:hours 或 days permissions: 'all', claudeAccountId: '', geminiAccountId: '', @@ -1185,6 +1199,40 @@ const removeTag = (index) => { form.tags.splice(index, 1) } +// 获取快捷时间选项 +const getQuickTimeOptions = () => { + if (form.activationUnit === 'hours') { + return [ + { value: 1, label: '1小时' }, + { value: 3, label: '3小时' }, + { value: 6, label: '6小时' }, + { value: 12, label: '12小时' } + ] + } else { + return [ + { value: 30, label: '30天' }, + { value: 90, label: '90天' }, + { value: 180, label: '180天' }, + { value: 365, label: '365天' } + ] + } +} + +// 单位变化时更新数值 +const updateActivationValue = () => { + if (form.activationUnit === 'hours') { + // 从天切换到小时,设置一个合理的默认值 + if (form.activationDays > 24) { + form.activationDays = 24 + } + } else { + // 从小时切换到天,设置一个合理的默认值 + if (form.activationDays < 1) { + form.activationDays = 1 + } + } +} + // 创建 API Key const createApiKey = async () => { // 验证表单 @@ -1260,6 +1308,7 @@ const createApiKey = async () => { expiresAt: form.expirationMode === 'fixed' ? form.expiresAt || undefined : undefined, expirationMode: form.expirationMode, activationDays: form.expirationMode === 'activation' ? form.activationDays : undefined, + activationUnit: form.expirationMode === 'activation' ? form.activationUnit : undefined, permissions: form.permissions, tags: form.tags.length > 0 ? form.tags : undefined, enableModelRestriction: form.enableModelRestriction, diff --git a/web/admin-spa/src/components/apikeys/ExpiryEditModal.vue b/web/admin-spa/src/components/apikeys/ExpiryEditModal.vue index 5509065e..1b450d11 100644 --- a/web/admin-spa/src/components/apikeys/ExpiryEditModal.vue +++ b/web/admin-spa/src/components/apikeys/ExpiryEditModal.vue @@ -46,7 +46,9 @@ 未激活 - (激活后 {{ apiKey.activationDays || 30 }} 天过期) + (激活后 + {{ apiKey.activationDays || (apiKey.activationUnit === 'hours' ? 24 : 30) }} + {{ apiKey.activationUnit === 'hours' ? '小时' : '天' }}过期) @@ -89,11 +91,15 @@ @click="handleActivateNow" > - 立即激活 (激活后 {{ apiKey.activationDays || 30 }} 天过期) + 立即激活 (激活后 + {{ apiKey.activationDays || (apiKey.activationUnit === 'hours' ? 24 : 30) }} + {{ apiKey.activationUnit === 'hours' ? '小时' : '天' }}过期)

- 点击立即激活此 API Key,激活后将在 {{ apiKey.activationDays || 30 }} 天后过期 + 点击立即激活此 API Key,激活后将在 + {{ apiKey.activationDays || (apiKey.activationUnit === 'hours' ? 24 : 30) }} + {{ apiKey.activationUnit === 'hours' ? '小时' : '天' }}后过期

@@ -400,14 +406,14 @@ const handleActivateNow = async () => { if (window.showConfirm) { confirmed = await window.showConfirm( '激活 API Key', - `确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || 30} 天后自动过期。`, + `确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || (props.apiKey.activationUnit === 'hours' ? 24 : 30)} ${props.apiKey.activationUnit === 'hours' ? '小时' : '天'}后自动过期。`, '确定激活', '取消' ) } else { // 降级方案 confirmed = confirm( - `确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || 30} 天后自动过期。` + `确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || (props.apiKey.activationUnit === 'hours' ? 24 : 30)} ${props.apiKey.activationUnit === 'hours' ? '小时' : '天'}后自动过期。` ) } diff --git a/web/admin-spa/src/components/apistats/StatsOverview.vue b/web/admin-spa/src/components/apistats/StatsOverview.vue index 5d4a9a6d..1ee5ad7f 100644 --- a/web/admin-spa/src/components/apistats/StatsOverview.vue +++ b/web/admin-spa/src/components/apistats/StatsOverview.vue @@ -123,7 +123,9 @@ 未激活 (首次使用后{{ statsData.activationDays || 30 }}天过期)(首次使用后 + {{ statsData.activationDays || (statsData.activationUnit === 'hours' ? 24 : 30) + }}{{ statsData.activationUnit === 'hours' ? '小时' : '天' }}过期) diff --git a/web/admin-spa/src/views/ApiKeysView.vue b/web/admin-spa/src/views/ApiKeysView.vue index 36ad6642..f237ad9b 100644 --- a/web/admin-spa/src/views/ApiKeysView.vue +++ b/web/admin-spa/src/views/ApiKeysView.vue @@ -661,7 +661,9 @@ style="font-size: 13px" > - 未激活 ({{ key.activationDays || 30 }}天) + 未激活 ( + {{ key.activationDays || (key.activationUnit === 'hours' ? 24 : 30) + }}{{ key.activationUnit === 'hours' ? '小时' : '天' }}) @@ -3471,7 +3473,86 @@ const exportToExcel = () => { // 基础数据 const baseData = { + ID: key.id || '', 名称: key.name || '', + 描述: key.description || '', + 状态: key.isActive ? '启用' : '禁用', + API密钥: key.apiKey || '', + + // 过期配置 + 过期模式: + key.expirationMode === 'activation' + ? '首次使用后激活' + : key.expirationMode === 'fixed' + ? '固定时间' + : '无', + 激活期限: key.activationDays || '', + 激活单位: + key.activationUnit === 'hours' ? '小时' : key.activationUnit === 'days' ? '天' : '', + 已激活: key.isActivated ? '是' : '否', + 激活时间: key.activatedAt ? formatDate(key.activatedAt) : '', + 过期时间: key.expiresAt ? formatDate(key.expiresAt) : '', + + // 权限配置 + 服务权限: + key.permissions === 'all' + ? '全部服务' + : key.permissions === 'claude' + ? '仅Claude' + : key.permissions === 'gemini' + ? '仅Gemini' + : key.permissions === 'openai' + ? '仅OpenAI' + : key.permissions || '', + + // 限制配置 + 令牌限制: key.tokenLimit === '0' || key.tokenLimit === 0 ? '无限制' : key.tokenLimit || '', + 并发限制: + key.concurrencyLimit === '0' || key.concurrencyLimit === 0 + ? '无限制' + : key.concurrencyLimit || '', + '速率窗口(分钟)': + key.rateLimitWindow === '0' || key.rateLimitWindow === 0 + ? '无限制' + : key.rateLimitWindow || '', + 速率请求限制: + key.rateLimitRequests === '0' || key.rateLimitRequests === 0 + ? '无限制' + : key.rateLimitRequests || '', + '日费用限制($)': + key.dailyCostLimit === '0' || key.dailyCostLimit === 0 + ? '无限制' + : `$${key.dailyCostLimit}` || '', + '总费用限制($)': + key.totalCostLimit === '0' || key.totalCostLimit === 0 + ? '无限制' + : `$${key.totalCostLimit}` || '', + + // 账户绑定 + Claude专属账户: key.claudeAccountId || '', + Claude控制台账户: key.claudeConsoleAccountId || '', + Gemini专属账户: key.geminiAccountId || '', + OpenAI专属账户: key.openaiAccountId || '', + 'Azure OpenAI专属账户': key.azureOpenaiAccountId || '', + Bedrock专属账户: key.bedrockAccountId || '', + + // 模型和客户端限制 + 启用模型限制: key.enableModelRestriction ? '是' : '否', + 限制的模型: + key.restrictedModels && key.restrictedModels.length > 0 + ? key.restrictedModels.join('; ') + : '', + 启用客户端限制: key.enableClientRestriction ? '是' : '否', + 允许的客户端: + key.allowedClients && key.allowedClients.length > 0 ? key.allowedClients.join('; ') : '', + + // 创建信息 + 创建时间: key.createdAt ? formatDate(key.createdAt) : '', + 创建者: key.createdBy || '', + 用户ID: key.userId || '', + 用户名: key.userUsername || '', + + // 使用统计 标签: key.tags && key.tags.length > 0 ? key.tags.join(', ') : '无', 请求总数: periodRequests, '总费用($)': periodCost.toFixed(2), @@ -3528,12 +3609,33 @@ const exportToExcel = () => { // 设置列宽 const headers = Object.keys(exportData[0] || {}) const columnWidths = headers.map((header) => { + // 基本信息字段 + if (header === 'ID') return { wch: 40 } if (header === '名称') return { wch: 25 } + if (header === '描述') return { wch: 30 } + if (header === 'API密钥') return { wch: 45 } if (header === '标签') return { wch: 20 } - if (header === '最后使用时间') return { wch: 20 } + + // 时间字段 + if (header.includes('时间')) return { wch: 20 } + + // 限制字段 + if (header.includes('限制')) return { wch: 15 } if (header.includes('费用')) return { wch: 15 } if (header.includes('Token')) return { wch: 15 } if (header.includes('请求')) return { wch: 12 } + + // 账户绑定字段 + if (header.includes('账户')) return { wch: 30 } + + // 权限配置字段 + if (header.includes('权限') || header.includes('模型') || header.includes('客户端')) + return { wch: 20 } + + // 激活配置字段 + if (header.includes('激活') || header.includes('过期')) return { wch: 18 } + + // 默认宽度 return { wch: 15 } }) ws['!cols'] = columnWidths From b9c606e82ece2e43776c9e3981c6bc74102cd360 Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 28 Sep 2025 09:57:40 +0800 Subject: [PATCH 03/13] Merge pr-484 --- scripts/data-transfer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/data-transfer.js b/scripts/data-transfer.js index 29439a4c..19950958 100644 --- a/scripts/data-transfer.js +++ b/scripts/data-transfer.js @@ -137,7 +137,7 @@ const CSV_FIELD_MAPPING = { tags: '标签', userId: '用户ID', userUsername: '用户名', - + // 其他信息 icon: '图标' } From 3077c3d7890f2ca617bdd8149b65d5ca37230459 Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 28 Sep 2025 10:53:57 +0800 Subject: [PATCH 04/13] =?UTF-8?q?docs:=20codex=E9=85=8D=E7=BD=AE=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 ++++++------ web/admin-spa/src/views/TutorialView.vue | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index da46c13d..3ddc562c 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,14 @@ --- -## 💎 Claude 拼车 - Claude Code 合租服务推荐 +## 💎 Claude/Codex 拼车服务推荐
-| 平台 | 类型 | 介绍 | -|:---:|:---:|:---| -| **[pincc.ai](https://pincc.ai/)** | 🏆 **官方运营** | 项目官方直营的Claude拼车服务
提供200刀 Claude Code Max 套餐共享服务 | -| **[ctok.ai](https://ctok.ai/)** | 🤝 合作伙伴 | 社区认可的Claude拼车服务 | +| 平台 | 类型 | 服务 | 介绍 | +|:---|:---|:---|:---| +| **[pincc.ai](https://pincc.ai/)** | 🏆 **官方运营** | ✅ Claude Code
✅ Codex CLI
| 项目直营,提供稳定的 Claude Code / Codex CLI 拼车服务 | +| **[ctok.ai](https://ctok.ai/)** | 🤝 合作伙伴 | ✅ Claude Code
✅ Codex CLI
| 社区认证,提供 Claude Code / Codex CLI 拼车 |
@@ -416,7 +416,7 @@ gemini # 或其他 Gemini CLI 命令 **Codex 配置:** -在 `~/.codex/config.toml` 文件中添加以下配置: +在 `~/.codex/config.toml` 文件**开头**添加以下配置: ```toml model_provider = "crs" diff --git a/web/admin-spa/src/views/TutorialView.vue b/web/admin-spa/src/views/TutorialView.vue index c293ade1..ab52cf1f 100644 --- a/web/admin-spa/src/views/TutorialView.vue +++ b/web/admin-spa/src/views/TutorialView.vue @@ -425,7 +425,7 @@

~/.codex/config.toml - 文件中添加以下配置: + 文件开头添加以下配置:

~/.codex/config.toml - 文件中添加以下配置: + 文件开头添加以下配置:

~/.codex/config.toml - 文件中添加以下配置: + 文件开头添加以下配置:

Date: Sun, 28 Sep 2025 11:47:05 +0800 Subject: [PATCH 05/13] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E7=AD=9B=E9=80=89=E5=B9=B3=E5=8F=B0=E6=98=AFoai?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/admin-spa/src/views/AccountsView.vue | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index b2a0a081..b37d709f 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -1655,24 +1655,12 @@ const paginatedAccounts = computed(() => { const loadAccounts = async (forceReload = false) => { accountsLoading.value = true try { - // 检查是否选择了特定分组 - if (groupFilter.value && groupFilter.value !== 'all' && groupFilter.value !== 'ungrouped') { - // 直接调用分组成员接口 - const response = await apiClient.get(`/admin/account-groups/${groupFilter.value}/members`) - if (response.success) { - // 分组成员接口已经包含了完整的账户信息,直接使用 - accounts.value = response.data - accountsLoading.value = false - return - } - } - // 构建查询参数(用于其他筛选情况) const params = {} if (platformFilter.value !== 'all') { params.platform = platformFilter.value } - if (groupFilter.value === 'ungrouped') { + if (groupFilter.value !== 'all') { params.groupId = groupFilter.value } From 5ce385d2bc1666f8b1f6cff48ab84be8bf71f192 Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 28 Sep 2025 11:53:46 +0800 Subject: [PATCH 06/13] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E7=AD=9B=E9=80=89=E5=B9=B3=E5=8F=B0=E6=98=AFoai?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/admin.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/routes/admin.js b/src/routes/admin.js index fc00448e..93d174dd 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -6882,6 +6882,16 @@ router.get('/openai-accounts', authenticateAdmin, async (req, res) => { const { platform, groupId } = req.query let accounts = await openaiAccountService.getAllAccounts() + // 缓存账户所属分组,避免重复查询 + const accountGroupCache = new Map() + const fetchAccountGroups = async (accountId) => { + if (!accountGroupCache.has(accountId)) { + const groups = await accountGroupService.getAccountGroups(accountId) + accountGroupCache.set(accountId, groups || []) + } + return accountGroupCache.get(accountId) + } + // 根据查询参数进行筛选 if (platform && platform !== 'all' && platform !== 'openai') { // 如果指定了其他平台,返回空数组 @@ -6894,7 +6904,7 @@ router.get('/openai-accounts', authenticateAdmin, async (req, res) => { // 筛选未分组账户 const filteredAccounts = [] for (const account of accounts) { - const groups = await accountGroupService.getAccountGroups(account.id) + const groups = await fetchAccountGroups(account.id) if (!groups || groups.length === 0) { filteredAccounts.push(account) } @@ -6912,8 +6922,10 @@ router.get('/openai-accounts', authenticateAdmin, async (req, res) => { accounts.map(async (account) => { try { const usageStats = await redis.getAccountUsageStats(account.id, 'openai') + const groupInfos = await fetchAccountGroups(account.id) return { ...account, + groupInfos, usage: { daily: usageStats.daily, total: usageStats.total, @@ -6922,8 +6934,10 @@ router.get('/openai-accounts', authenticateAdmin, async (req, res) => { } } catch (error) { logger.debug(`Failed to get usage stats for OpenAI account ${account.id}:`, error) + const groupInfos = await fetchAccountGroups(account.id) return { ...account, + groupInfos, usage: { daily: { requests: 0, tokens: 0, allTokens: 0 }, total: { requests: 0, tokens: 0, allTokens: 0 }, From 90dce32cfc2583e116111b612c0dc90fe6aa4aa1 Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 28 Sep 2025 13:58:59 +0800 Subject: [PATCH 07/13] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E9=99=90=E5=88=B6=E6=95=B0=E7=9A=84=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/middleware/auth.js | 53 +++++++++++++- src/models/redis.js | 153 +++++++++++++++++++++++++++++++++-------- 2 files changed, 176 insertions(+), 30 deletions(-) diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 241ae568..2b55d69f 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -1,3 +1,5 @@ +const { v4: uuidv4 } = require('uuid') +const config = require('../../config/config') const apiKeyService = require('../services/apiKeyService') const userService = require('../services/userService') const logger = require('../utils/logger') @@ -80,14 +82,33 @@ const authenticateApiKey = async (req, res, next) => { // 检查并发限制 const concurrencyLimit = validation.keyData.concurrencyLimit || 0 if (concurrencyLimit > 0) { - const currentConcurrency = await redis.incrConcurrency(validation.keyData.id) + const concurrencyConfig = config.concurrency || {} + const leaseSeconds = Math.max(concurrencyConfig.leaseSeconds || 900, 30) + const rawRenewInterval = + typeof concurrencyConfig.renewIntervalSeconds === 'number' + ? concurrencyConfig.renewIntervalSeconds + : 60 + let renewIntervalSeconds = rawRenewInterval + if (renewIntervalSeconds > 0) { + const maxSafeRenew = Math.max(leaseSeconds - 5, 15) + renewIntervalSeconds = Math.min(Math.max(renewIntervalSeconds, 15), maxSafeRenew) + } else { + renewIntervalSeconds = 0 + } + const requestId = uuidv4() + + const currentConcurrency = await redis.incrConcurrency( + validation.keyData.id, + requestId, + leaseSeconds + ) logger.api( `📈 Incremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), current: ${currentConcurrency}, limit: ${concurrencyLimit}` ) if (currentConcurrency > concurrencyLimit) { // 如果超过限制,立即减少计数 - await redis.decrConcurrency(validation.keyData.id) + await redis.decrConcurrency(validation.keyData.id, requestId) logger.security( `🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${ validation.keyData.name @@ -101,14 +122,39 @@ const authenticateApiKey = async (req, res, next) => { }) } + const renewIntervalMs = + renewIntervalSeconds > 0 ? Math.max(renewIntervalSeconds * 1000, 15000) : 0 + // 使用标志位确保只减少一次 let concurrencyDecremented = false + let leaseRenewInterval = null + + if (renewIntervalMs > 0) { + leaseRenewInterval = setInterval(() => { + redis + .refreshConcurrencyLease(validation.keyData.id, requestId, leaseSeconds) + .catch((error) => { + logger.error( + `Failed to refresh concurrency lease for key ${validation.keyData.id}:`, + error + ) + }) + }, renewIntervalMs) + + if (typeof leaseRenewInterval.unref === 'function') { + leaseRenewInterval.unref() + } + } const decrementConcurrency = async () => { if (!concurrencyDecremented) { concurrencyDecremented = true + if (leaseRenewInterval) { + clearInterval(leaseRenewInterval) + leaseRenewInterval = null + } try { - const newCount = await redis.decrConcurrency(validation.keyData.id) + const newCount = await redis.decrConcurrency(validation.keyData.id, requestId) logger.api( `📉 Decremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), new count: ${newCount}` ) @@ -147,6 +193,7 @@ const authenticateApiKey = async (req, res, next) => { req.concurrencyInfo = { apiKeyId: validation.keyData.id, apiKeyName: validation.keyData.name, + requestId, decrementConcurrency } } diff --git a/src/models/redis.js b/src/models/redis.js index 65a89b54..668f5a2c 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -1538,18 +1538,55 @@ class RedisClient { } } - // 增加并发计数 - async incrConcurrency(apiKeyId) { + // 获取并发配置 + _getConcurrencyConfig() { + const defaults = { + leaseSeconds: 900, + cleanupGraceSeconds: 30 + } + return { + ...defaults, + ...(config.concurrency || {}) + } + } + + // 增加并发计数(基于租约的有序集合) + async incrConcurrency(apiKeyId, requestId, leaseSeconds = null) { + if (!requestId) { + throw new Error('Request ID is required for concurrency tracking') + } + try { + const { leaseSeconds: defaultLeaseSeconds, cleanupGraceSeconds } = + this._getConcurrencyConfig() + const lease = leaseSeconds || defaultLeaseSeconds const key = `concurrency:${apiKeyId}` - const count = await this.client.incr(key) + const now = Date.now() + const expireAt = now + lease * 1000 + const ttl = Math.max((lease + cleanupGraceSeconds) * 1000, 60000) - // 设置过期时间为180秒(3分钟),防止计数器永远不清零 - // 正常情况下请求会在完成时主动减少计数,这只是一个安全保障 - // 180秒足够支持较长的流式请求 - await this.client.expire(key, 180) + const luaScript = ` + local key = KEYS[1] + local member = ARGV[1] + local expireAt = tonumber(ARGV[2]) + local now = tonumber(ARGV[3]) + local ttl = tonumber(ARGV[4]) - logger.database(`🔢 Incremented concurrency for key ${apiKeyId}: ${count}`) + redis.call('ZREMRANGEBYSCORE', key, '-inf', now) + redis.call('ZADD', key, expireAt, member) + + if ttl > 0 then + redis.call('PEXPIRE', key, ttl) + end + + local count = redis.call('ZCARD', key) + return count + ` + + const count = await this.client.eval(luaScript, 1, key, requestId, expireAt, now, ttl) + logger.database( + `🔢 Incremented concurrency for key ${apiKeyId}: ${count} (request ${requestId})` + ) return count } catch (error) { logger.error('❌ Failed to increment concurrency:', error) @@ -1557,32 +1594,84 @@ class RedisClient { } } - // 减少并发计数 - async decrConcurrency(apiKeyId) { - try { - const key = `concurrency:${apiKeyId}` + // 刷新并发租约,防止长连接提前过期 + async refreshConcurrencyLease(apiKeyId, requestId, leaseSeconds = null) { + if (!requestId) { + return 0 + } + + try { + const { leaseSeconds: defaultLeaseSeconds, cleanupGraceSeconds } = + this._getConcurrencyConfig() + const lease = leaseSeconds || defaultLeaseSeconds + const key = `concurrency:${apiKeyId}` + const now = Date.now() + const expireAt = now + lease * 1000 + const ttl = Math.max((lease + cleanupGraceSeconds) * 1000, 60000) - // 使用Lua脚本确保原子性操作,防止计数器变成负数 const luaScript = ` local key = KEYS[1] - local current = tonumber(redis.call('get', key) or "0") + local member = ARGV[1] + local expireAt = tonumber(ARGV[2]) + local now = tonumber(ARGV[3]) + local ttl = tonumber(ARGV[4]) - if current <= 0 then - redis.call('del', key) - return 0 - else - local new_value = redis.call('decr', key) - if new_value <= 0 then - redis.call('del', key) - return 0 - else - return new_value + local exists = redis.call('ZSCORE', key, member) + + redis.call('ZREMRANGEBYSCORE', key, '-inf', now) + + if exists then + redis.call('ZADD', key, expireAt, member) + if ttl > 0 then + redis.call('PEXPIRE', key, ttl) end + return 1 end + + return 0 ` - const count = await this.client.eval(luaScript, 1, key) - logger.database(`🔢 Decremented concurrency for key ${apiKeyId}: ${count}`) + const refreshed = await this.client.eval(luaScript, 1, key, requestId, expireAt, now, ttl) + if (refreshed === 1) { + logger.debug(`🔄 Refreshed concurrency lease for key ${apiKeyId} (request ${requestId})`) + } + return refreshed + } catch (error) { + logger.error('❌ Failed to refresh concurrency lease:', error) + return 0 + } + } + + // 减少并发计数 + async decrConcurrency(apiKeyId, requestId) { + try { + const key = `concurrency:${apiKeyId}` + const now = Date.now() + + const luaScript = ` + local key = KEYS[1] + local member = ARGV[1] + local now = tonumber(ARGV[2]) + + if member then + redis.call('ZREM', key, member) + end + + redis.call('ZREMRANGEBYSCORE', key, '-inf', now) + + local count = redis.call('ZCARD', key) + if count <= 0 then + redis.call('DEL', key) + return 0 + end + + return count + ` + + const count = await this.client.eval(luaScript, 1, key, requestId || '', now) + logger.database( + `🔢 Decremented concurrency for key ${apiKeyId}: ${count} (request ${requestId || 'n/a'})` + ) return count } catch (error) { logger.error('❌ Failed to decrement concurrency:', error) @@ -1594,7 +1683,17 @@ class RedisClient { async getConcurrency(apiKeyId) { try { const key = `concurrency:${apiKeyId}` - const count = await this.client.get(key) + const now = Date.now() + + const luaScript = ` + local key = KEYS[1] + local now = tonumber(ARGV[1]) + + redis.call('ZREMRANGEBYSCORE', key, '-inf', now) + return redis.call('ZCARD', key) + ` + + const count = await this.client.eval(luaScript, 1, key, now) return parseInt(count || 0) } catch (error) { logger.error('❌ Failed to get concurrency:', error) From b123cc35c1c069cce73f948149418fb63c0c15aa Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 28 Sep 2025 14:36:38 +0800 Subject: [PATCH 08/13] =?UTF-8?q?feat:=20api-stats=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E4=B8=93=E5=B1=9E=E8=B4=A6=E5=8F=B7=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/apiStats.js | 53 ++- src/services/claudeAccountService.js | 54 +++ src/services/openaiAccountService.js | 43 ++ .../src/components/apistats/StatsOverview.vue | 449 ++++++++++++++++++ 4 files changed, 598 insertions(+), 1 deletion(-) diff --git a/src/routes/apiStats.js b/src/routes/apiStats.js index a121fe3e..322a9e3c 100644 --- a/src/routes/apiStats.js +++ b/src/routes/apiStats.js @@ -3,6 +3,8 @@ const redis = require('../models/redis') const logger = require('../utils/logger') const apiKeyService = require('../services/apiKeyService') const CostCalculator = require('../utils/costCalculator') +const claudeAccountService = require('../services/claudeAccountService') +const openaiAccountService = require('../services/openaiAccountService') const router = express.Router() @@ -335,6 +337,50 @@ router.post('/api/user-stats', async (req, res) => { logger.warn(`Failed to get current usage for key ${keyId}:`, error) } + const boundAccountDetails = {} + + const accountDetailTasks = [] + + if (fullKeyData.claudeAccountId) { + accountDetailTasks.push( + (async () => { + try { + const overview = await claudeAccountService.getAccountOverview( + fullKeyData.claudeAccountId + ) + + if (overview && overview.accountType === 'dedicated') { + boundAccountDetails.claude = overview + } + } catch (error) { + logger.warn(`⚠️ Failed to load Claude account overview for key ${keyId}:`, error) + } + })() + ) + } + + if (fullKeyData.openaiAccountId) { + accountDetailTasks.push( + (async () => { + try { + const overview = await openaiAccountService.getAccountOverview( + fullKeyData.openaiAccountId + ) + + if (overview && overview.accountType === 'dedicated') { + boundAccountDetails.openai = overview + } + } catch (error) { + logger.warn(`⚠️ Failed to load OpenAI account overview for key ${keyId}:`, error) + } + })() + ) + } + + if (accountDetailTasks.length > 0) { + await Promise.allSettled(accountDetailTasks) + } + // 构建响应数据(只返回该API Key自己的信息,确保不泄露其他信息) const responseData = { id: keyId, @@ -399,7 +445,12 @@ router.post('/api/user-stats', async (req, res) => { geminiAccountId: fullKeyData.geminiAccountId && fullKeyData.geminiAccountId !== '' ? fullKeyData.geminiAccountId - : null + : null, + openaiAccountId: + fullKeyData.openaiAccountId && fullKeyData.openaiAccountId !== '' + ? fullKeyData.openaiAccountId + : null, + details: Object.keys(boundAccountDetails).length > 0 ? boundAccountDetails : null }, // 模型和客户端限制信息 diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 1615d7e7..7b255af0 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -518,6 +518,60 @@ class ClaudeAccountService { } } + // 📋 获取单个账号的概要信息(用于前端展示会话窗口等状态) + async getAccountOverview(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + + if (!accountData || Object.keys(accountData).length === 0) { + return null + } + + const [sessionWindowInfo, rateLimitInfo] = await Promise.all([ + this.getSessionWindowInfo(accountId), + this.getAccountRateLimitInfo(accountId) + ]) + + const sessionWindow = sessionWindowInfo || { + hasActiveWindow: false, + windowStart: null, + windowEnd: null, + progress: 0, + remainingTime: null, + lastRequestTime: accountData.lastRequestTime || null, + sessionWindowStatus: accountData.sessionWindowStatus || null + } + + const rateLimitStatus = rateLimitInfo + ? { + isRateLimited: !!rateLimitInfo.isRateLimited, + rateLimitedAt: rateLimitInfo.rateLimitedAt || null, + minutesRemaining: rateLimitInfo.minutesRemaining || 0, + rateLimitEndAt: rateLimitInfo.rateLimitEndAt || null + } + : { + isRateLimited: false, + rateLimitedAt: null, + minutesRemaining: 0, + rateLimitEndAt: null + } + + return { + id: accountData.id, + name: accountData.name, + accountType: accountData.accountType || 'shared', + platform: accountData.platform || 'claude', + isActive: accountData.isActive === 'true', + schedulable: accountData.schedulable !== 'false', + sessionWindow, + rateLimitStatus + } + } catch (error) { + logger.error(`❌ Failed to build Claude account overview for ${accountId}:`, error) + return null + } + } + // 📝 更新Claude账户 async updateAccount(accountId, updates) { try { diff --git a/src/services/openaiAccountService.js b/src/services/openaiAccountService.js index 47a9cb46..6ad7ea70 100644 --- a/src/services/openaiAccountService.js +++ b/src/services/openaiAccountService.js @@ -808,6 +808,48 @@ async function getAllAccounts() { return accounts } +// 获取单个账户的概要信息(用于外部展示基本状态) +async function getAccountOverview(accountId) { + const client = redisClient.getClientSafe() + const accountData = await client.hgetall(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`) + + if (!accountData || Object.keys(accountData).length === 0) { + return null + } + + const codexUsage = buildCodexUsageSnapshot(accountData) + const rateLimitInfo = await getAccountRateLimitInfo(accountId) + + if (accountData.proxy) { + try { + accountData.proxy = JSON.parse(accountData.proxy) + } catch (error) { + accountData.proxy = null + } + } + + const scopes = + accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [] + + return { + id: accountData.id, + name: accountData.name, + accountType: accountData.accountType || 'shared', + platform: accountData.platform || 'openai', + isActive: accountData.isActive === 'true', + schedulable: accountData.schedulable !== 'false', + rateLimitStatus: rateLimitInfo || { + status: 'normal', + isRateLimited: false, + rateLimitedAt: null, + rateLimitResetAt: null, + minutesRemaining: 0 + }, + codexUsage, + scopes + } +} + // 选择可用账户(支持专属和共享账户) async function selectAvailableAccount(apiKeyId, sessionHash = null) { // 首先检查是否有粘性会话 @@ -1175,6 +1217,7 @@ async function updateCodexUsageSnapshot(accountId, usageSnapshot) { module.exports = { createAccount, getAccount, + getAccountOverview, updateAccount, deleteAccount, getAllAccounts, diff --git a/web/admin-spa/src/components/apistats/StatsOverview.vue b/web/admin-spa/src/components/apistats/StatsOverview.vue index 1ee5ad7f..db782e48 100644 --- a/web/admin-spa/src/components/apistats/StatsOverview.vue +++ b/web/admin-spa/src/components/apistats/StatsOverview.vue @@ -157,6 +157,200 @@ 永不过期
+ +
+
+
+ + 专属账号运行状态 +
+ 实时速览 +
+ +
+
+
+
+
+ +
+
+
+ {{ account.name || '未命名账号' }} +
+
+ Claude 专属 + OpenAI 专属 +
+
+
+
+ + {{ getRateLimitDisplay(account.rateLimitStatus).text }} +
+
+ +
+
+
+
+
+
+ + {{ + Math.min( + 100, + Math.max(0, Math.round(account.sessionWindow?.progress || 0)) + ) + }}% + +
+
+ + {{ + formatSessionWindowRange( + account.sessionWindow?.windowStart, + account.sessionWindow?.windowEnd + ) + }} + + + 剩余 {{ formatSessionRemaining(account.sessionWindow.remainingTime) }} + +
+
+
+ 暂无活跃会话窗口 +
+
+ +
+
+
+
+ + {{ getCodexWindowLabel('primary') }} + +
+
+
+
+
+ + {{ formatCodexUsagePercent(account.codexUsage.primary) }} + +
+
+
+
+ 重置剩余 {{ formatCodexRemaining(account.codexUsage.primary) }} +
+
+ +
+
+ + {{ getCodexWindowLabel('secondary') }} + +
+
+
+
+
+ + {{ formatCodexUsagePercent(account.codexUsage.secondary) }} + +
+
+
+
+ 重置剩余 {{ formatCodexRemaining(account.codexUsage.secondary) }} +
+
+
+
+ 暂无额度使用数据 +
+
+
+
+
@@ -313,6 +507,261 @@ const formatPermissions = (permissions) => { return permissionMap[permissions] || permissions || '未知' } + +// 绑定的专属账号列表(仅保留专属类型) +const boundAccountList = computed(() => { + const accounts = statsData.value?.accounts?.details + if (!accounts) { + return [] + } + + const result = [] + + if (accounts.claude && accounts.claude.accountType === 'dedicated') { + result.push({ key: 'claude', ...accounts.claude }) + } + + if (accounts.openai && accounts.openai.accountType === 'dedicated') { + result.push({ key: 'openai', ...accounts.openai }) + } + + return result +}) + +// 将分钟格式化为易读文本 +const formatRateLimitTime = (minutes) => { + if (!minutes || minutes <= 0) { + return '' + } + + const totalMinutes = Math.floor(minutes) + const days = Math.floor(totalMinutes / 1440) + const hours = Math.floor((totalMinutes % 1440) / 60) + const mins = totalMinutes % 60 + + if (days > 0) { + if (hours > 0) { + return `${days}天${hours}小时` + } + return `${days}天` + } + + if (hours > 0) { + if (mins > 0) { + return `${hours}小时${mins}分钟` + } + return `${hours}小时` + } + + return `${mins}分钟` +} + +// 生成限流状态的展示信息 +const getRateLimitDisplay = (status) => { + if (!status) { + return { + text: '状态未知', + class: 'text-gray-400' + } + } + + if (status.isRateLimited) { + const remaining = formatRateLimitTime(status.minutesRemaining) + const suffix = remaining ? ` · 剩余约 ${remaining}` : '' + return { + text: `限流中${suffix}`, + class: 'text-red-500 dark:text-red-400' + } + } + + return { + text: '未限流', + class: 'text-green-600 dark:text-green-400' + } +} + +// 格式化会话窗口的时间范围 +const formatSessionWindowRange = (start, end) => { + if (!start || !end) { + return '暂无时间窗口信息' + } + + const startDate = new Date(start) + const endDate = new Date(end) + + const formatPart = (date) => { + const hours = `${date.getHours()}`.padStart(2, '0') + const minutes = `${date.getMinutes()}`.padStart(2, '0') + return `${hours}:${minutes}` + } + + return `${formatPart(startDate)} - ${formatPart(endDate)}` +} + +// 格式化会话窗口剩余时间 +const formatSessionRemaining = (minutes) => { + if (!minutes || minutes <= 0) { + return '' + } + + const hours = Math.floor(minutes / 60) + const mins = minutes % 60 + + if (hours > 0) { + return `${hours}小时${mins}分钟` + } + return `${mins}分钟` +} + +// 会话窗口进度条颜色 +const getSessionProgressBarClass = (status, account) => { + if (!status) { + return 'bg-gradient-to-r from-blue-500 to-indigo-600' + } + + const isRateLimited = account?.rateLimitStatus?.isRateLimited + + if (isRateLimited) { + return 'bg-gradient-to-r from-red-500 to-red-600' + } + + const normalized = String(status).toLowerCase() + + if (normalized === 'rejected') { + return 'bg-gradient-to-r from-red-500 to-red-600' + } + + if (normalized === 'allowed_warning') { + return 'bg-gradient-to-r from-yellow-500 to-orange-500' + } + + return 'bg-gradient-to-r from-blue-500 to-indigo-600' +} + +// 归一化 OpenAI 额度使用百分比 +const normalizeCodexUsagePercent = (usageItem) => { + if (!usageItem) { + return null + } + + const percent = + typeof usageItem.usedPercent === 'number' && !Number.isNaN(usageItem.usedPercent) + ? usageItem.usedPercent + : null + + const resetAfterSeconds = + typeof usageItem.resetAfterSeconds === 'number' && !Number.isNaN(usageItem.resetAfterSeconds) + ? usageItem.resetAfterSeconds + : null + + const remainingSeconds = + typeof usageItem.remainingSeconds === 'number' ? usageItem.remainingSeconds : null + + const resetAtMs = usageItem.resetAt ? Date.parse(usageItem.resetAt) : null + + const resetElapsed = + resetAfterSeconds !== null && + ((remainingSeconds !== null && remainingSeconds <= 0) || + (resetAtMs !== null && !Number.isNaN(resetAtMs) && Date.now() >= resetAtMs)) + + if (resetElapsed) { + return 0 + } + + if (percent === null) { + return null + } + + return Math.max(0, Math.min(100, percent)) +} + +// OpenAI 额度进度条颜色 +const getCodexUsageBarClass = (usageItem) => { + const percent = normalizeCodexUsagePercent(usageItem) + + if (percent === null) { + return 'bg-gradient-to-r from-gray-300 to-gray-400' + } + + if (percent >= 90) { + return 'bg-gradient-to-r from-red-500 to-red-600' + } + + if (percent >= 75) { + return 'bg-gradient-to-r from-yellow-500 to-orange-500' + } + + return 'bg-gradient-to-r from-emerald-500 to-teal-500' +} + +// OpenAI 额度进度条宽度 +const getCodexUsageWidth = (usageItem) => { + const percent = normalizeCodexUsagePercent(usageItem) + if (percent === null) { + return '0%' + } + return `${percent}%` +} + +// OpenAI 额度百分比文本 +const formatCodexUsagePercent = (usageItem) => { + const percent = normalizeCodexUsagePercent(usageItem) + if (percent === null) { + return '--' + } + return `${percent.toFixed(1)}%` +} + +// OpenAI 额度剩余时间 +const formatCodexRemaining = (usageItem) => { + if (!usageItem) { + return '--' + } + + let seconds = usageItem.remainingSeconds + if (seconds === null || seconds === undefined) { + seconds = usageItem.resetAfterSeconds + } + + if (seconds === null || seconds === undefined || Number.isNaN(Number(seconds))) { + return '--' + } + + seconds = Math.max(0, Math.floor(Number(seconds))) + + const days = Math.floor(seconds / 86400) + const hours = Math.floor((seconds % 86400) / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const secs = seconds % 60 + + if (days > 0) { + if (hours > 0) { + return `${days}天${hours}小时` + } + return `${days}天` + } + + if (hours > 0) { + if (minutes > 0) { + return `${hours}小时${minutes}分钟` + } + return `${hours}小时` + } + + if (minutes > 0) { + return `${minutes}分钟` + } + + return `${secs}秒` +} + +// OpenAI 窗口标签 +const getCodexWindowLabel = (type) => { + if (type === 'secondary') { + return '周限' + } + return '5h' +} diff --git a/web/admin-spa/src/components/apistats/TokenDistribution.vue b/web/admin-spa/src/components/apistats/TokenDistribution.vue index 56baa2bc..ac7b1404 100644 --- a/web/admin-spa/src/components/apistats/TokenDistribution.vue +++ b/web/admin-spa/src/components/apistats/TokenDistribution.vue @@ -95,6 +95,9 @@ const formatNumber = (num) => { box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + display: flex; + flex-direction: column; + height: 100%; overflow: hidden; position: relative; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); diff --git a/web/admin-spa/src/views/ApiStatsView.vue b/web/admin-spa/src/views/ApiStatsView.vue index e20e16b3..fb3296bc 100644 --- a/web/admin-spa/src/views/ApiStatsView.vue +++ b/web/admin-spa/src/views/ApiStatsView.vue @@ -124,19 +124,15 @@
-
- -
- -
- -
- -
- -
+ + +
From 506bd5a205656830819fc58968dcfa1a4bed3345 Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 28 Sep 2025 15:52:33 +0800 Subject: [PATCH 11/13] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Dredis=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E4=BC=A0=E5=8F=82=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/manage.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/manage.sh b/scripts/manage.sh index f8530b83..181bb2be 100755 --- a/scripts/manage.sh +++ b/scripts/manage.sh @@ -288,12 +288,12 @@ check_redis() { # 测试Redis连接 print_info "测试 Redis 连接..." if command_exists redis-cli; then - local redis_test_cmd="redis-cli -h $REDIS_HOST -p $REDIS_PORT" + local redis_args=(-h "$REDIS_HOST" -p "$REDIS_PORT") if [ -n "$REDIS_PASSWORD" ]; then - redis_test_cmd="$redis_test_cmd -a '$REDIS_PASSWORD'" + redis_args+=(-a "$REDIS_PASSWORD") fi - - if $redis_test_cmd ping 2>/dev/null | grep -q "PONG"; then + + if redis-cli "${redis_args[@]}" ping 2>/dev/null | grep -q "PONG"; then print_success "Redis 连接成功" return 0 else @@ -1754,4 +1754,4 @@ main() { } # 运行主函数 -main "$@" \ No newline at end of file +main "$@" From aca2b1cccb9ba2f26ec5957e2103c5b931de299a Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 28 Sep 2025 21:43:57 +0800 Subject: [PATCH 12/13] =?UTF-8?q?feat:=20=E8=B4=A6=E5=8F=B7=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=94=AF=E6=8C=81=E6=89=B9=E9=87=8F=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/admin.js | 6 +- web/admin-spa/src/views/AccountsView.vue | 332 +++++++++++++++++++---- 2 files changed, 286 insertions(+), 52 deletions(-) diff --git a/src/routes/admin.js b/src/routes/admin.js index 93d174dd..b079419e 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -2275,7 +2275,7 @@ router.delete('/claude-accounts/:accountId', authenticateAdmin, async (req, res) // 获取账户信息以检查是否在分组中 const account = await claudeAccountService.getAccount(accountId) if (account && account.accountType === 'group') { - const groups = await accountGroupService.getAccountGroup(accountId) + const groups = await accountGroupService.getAccountGroups(accountId) for (const group of groups) { await accountGroupService.removeAccountFromGroup(accountId, group.id) } @@ -2665,7 +2665,7 @@ router.delete('/claude-console-accounts/:accountId', authenticateAdmin, async (r // 获取账户信息以检查是否在分组中 const account = await claudeConsoleAccountService.getAccount(accountId) if (account && account.accountType === 'group') { - const groups = await accountGroupService.getAccountGroup(accountId) + const groups = await accountGroupService.getAccountGroups(accountId) for (const group of groups) { await accountGroupService.removeAccountFromGroup(accountId, group.id) } @@ -3900,7 +3900,7 @@ router.delete('/gemini-accounts/:accountId', authenticateAdmin, async (req, res) // 获取账户信息以检查是否在分组中 const account = await geminiAccountService.getAccount(accountId) if (account && account.accountType === 'group') { - const groups = await accountGroupService.getAccountGroup(accountId) + const groups = await accountGroupService.getAccountGroups(accountId) for (const group of groups) { await accountGroupService.removeAccountFromGroup(accountId, group.id) } diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index b37d709f..0dc6600c 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -111,6 +111,28 @@
+ + + + + +