From 76ecbe18a56fb467906336ac1429bdb65ee213c0 Mon Sep 17 00:00:00 2001 From: SunSeekerX Date: Mon, 19 Jan 2026 20:24:47 +0800 Subject: [PATCH] 1 --- config/config.example.js | 3 +- config/models.js | 64 ++ src/app.js | 21 + src/handlers/geminiHandlers.js | 26 +- src/middleware/auth.js | 4 +- src/models/redis.js | 273 ++++- src/routes/admin/apiKeys.js | 27 +- src/routes/admin/azureOpenaiAccounts.js | 80 ++ src/routes/admin/ccrAccounts.js | 85 ++ src/routes/admin/dashboard.js | 65 +- src/routes/admin/droidAccounts.js | 151 ++- src/routes/admin/geminiAccounts.js | 85 ++ src/routes/admin/index.js | 4 + src/routes/admin/openaiResponsesAccounts.js | 81 ++ src/routes/admin/quotaCards.js | 329 ++++++ src/routes/admin/serviceRates.js | 72 ++ src/routes/admin/system.js | 12 +- src/routes/admin/usageStats.js | 9 +- src/routes/apiStats.js | 401 ++++++- src/routes/openaiRoutes.js | 32 +- src/routes/userRoutes.js | 162 +++ src/services/accountGroupService.js | 28 + src/services/apiKeyService.js | 520 ++++++++- src/services/quotaCardService.js | 579 ++++++++++ src/services/serviceRatesService.js | 227 ++++ src/utils/commonHelper.js | 51 +- src/utils/testPayloadHelper.js | 58 +- web/admin-spa/src/App.vue | 3 - .../src/assets/styles/components.css | 42 +- web/admin-spa/src/assets/styles/global.css | 172 ++- web/admin-spa/src/assets/styles/main.css | 32 +- .../src/components/accounts/AccountForm.vue | 46 +- .../accounts/AccountScheduledTestModal.vue | 4 +- .../components/accounts/AccountTestModal.vue | 190 +++- .../accounts/ApiKeyManagementModal.vue | 102 +- .../components/accounts/CcrAccountForm.vue | 8 +- .../accounts/GroupManagementModal.vue | 264 +++-- .../src/components/accounts/OAuthFlow.vue | 2 +- .../src/components/admin/ChangeRoleModal.vue | 6 +- .../components/admin/UserUsageStatsModal.vue | 8 +- .../components/apikeys/ApiKeyTestModal.vue | 153 ++- .../components/apikeys/BatchApiKeyModal.vue | 100 +- .../apikeys/BatchEditApiKeyModal.vue | 24 +- .../components/apikeys/CreateApiKeyModal.vue | 175 +++- .../components/apikeys/EditApiKeyModal.vue | 187 +++- .../components/apikeys/ExpiryEditModal.vue | 68 +- .../src/components/apikeys/NewApiKeyModal.vue | 100 +- .../components/apikeys/RecordDetailModal.vue | 2 +- .../components/apikeys/RenewApiKeyModal.vue | 6 +- .../src/components/apistats/ApiKeyInput.vue | 34 +- .../src/components/apistats/LimitConfig.vue | 6 +- .../components/apistats/ModelUsageStats.vue | 143 ++- .../components/apistats/ServiceCostCards.vue | 256 +++++ .../src/components/apistats/StatsOverview.vue | 16 +- .../components/apistats/TokenDistribution.vue | 4 +- .../src/components/common/ConfirmDialog.vue | 48 +- .../src/components/common/ConfirmModal.vue | 34 +- .../src/components/common/ThemeToggle.vue | 220 +++- .../components/common/ToastNotification.vue | 8 +- .../dashboard/ModelDistribution.vue | 2 +- .../src/components/dashboard/UsageTrend.vue | 18 +- .../src/components/layout/AppHeader.vue | 73 +- .../src/components/layout/MainLayout.vue | 3 + .../src/components/layout/TabBar.vue | 3 +- .../src/components/user/CreateApiKeyModal.vue | 2 +- .../components/user/UserApiKeysManager.vue | 2 +- .../src/components/user/UserUsageStats.vue | 2 +- .../src/components/user/ViewApiKeyModal.vue | 2 +- .../src/composables/useChartConfig.js | 17 +- web/admin-spa/src/config/api.js | 210 ---- web/admin-spa/src/config/apiStats.js | 97 -- web/admin-spa/src/config/app.js | 17 +- web/admin-spa/src/router/index.js | 16 +- web/admin-spa/src/stores/accounts.js | 138 +-- web/admin-spa/src/stores/apiKeys.js | 44 +- web/admin-spa/src/stores/apistats.js | 116 +- web/admin-spa/src/stores/auth.js | 13 +- web/admin-spa/src/stores/clients.js | 5 +- web/admin-spa/src/stores/dashboard.js | 18 +- web/admin-spa/src/stores/settings.js | 16 +- web/admin-spa/src/stores/theme.js | 250 +++++ web/admin-spa/src/stores/user.js | 4 +- web/admin-spa/src/utils/format.js | 74 -- web/admin-spa/src/utils/http_apis.js | 262 +++++ web/admin-spa/src/utils/toast.js | 71 -- web/admin-spa/src/utils/tools.js | 128 +++ .../src/views/AccountUsageRecordsView.vue | 10 +- web/admin-spa/src/views/AccountsView.vue | 433 +++----- .../src/views/ApiKeyUsageRecordsView.vue | 10 +- web/admin-spa/src/views/ApiKeysView.vue | 332 +++--- web/admin-spa/src/views/ApiStatsView.vue | 327 +++++- web/admin-spa/src/views/DashboardView.vue | 25 +- web/admin-spa/src/views/QuotaCardsView.vue | 987 ++++++++++++++++++ web/admin-spa/src/views/SettingsView.vue | 397 ++++++- web/admin-spa/src/views/UserDashboardView.vue | 2 +- web/admin-spa/src/views/UserLoginView.vue | 2 +- .../src/views/UserManagementView.vue | 12 +- web/admin-spa/tailwind.config.js | 26 + 98 files changed, 8182 insertions(+), 1896 deletions(-) create mode 100644 config/models.js create mode 100644 src/routes/admin/quotaCards.js create mode 100644 src/routes/admin/serviceRates.js create mode 100644 src/services/quotaCardService.js create mode 100644 src/services/serviceRatesService.js create mode 100644 web/admin-spa/src/components/apistats/ServiceCostCards.vue delete mode 100644 web/admin-spa/src/config/api.js delete mode 100644 web/admin-spa/src/config/apiStats.js delete mode 100644 web/admin-spa/src/utils/format.js create mode 100644 web/admin-spa/src/utils/http_apis.js delete mode 100644 web/admin-spa/src/utils/toast.js create mode 100644 web/admin-spa/src/utils/tools.js create mode 100644 web/admin-spa/src/views/QuotaCardsView.vue diff --git a/config/config.example.js b/config/config.example.js index 9cf26002..780e8360 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -123,7 +123,8 @@ const config = { tokenUsageRetention: parseInt(process.env.TOKEN_USAGE_RETENTION) || 2592000000, // 30天 healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 60000, // 1分钟 timezone: process.env.SYSTEM_TIMEZONE || 'Asia/Shanghai', // 默认UTC+8(中国时区) - timezoneOffset: parseInt(process.env.TIMEZONE_OFFSET) || 8 // UTC偏移小时数,默认+8 + timezoneOffset: parseInt(process.env.TIMEZONE_OFFSET) || 8, // UTC偏移小时数,默认+8 + metricsWindow: parseInt(process.env.METRICS_WINDOW) || 5 // 实时指标统计窗口(分钟) }, // 🎨 Web界面配置 diff --git a/config/models.js b/config/models.js new file mode 100644 index 00000000..f3804a4c --- /dev/null +++ b/config/models.js @@ -0,0 +1,64 @@ +/** + * 模型列表配置 + * 用于前端展示和测试功能 + */ + +const CLAUDE_MODELS = [ + { value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' }, + { value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' }, + { value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' }, + { value: 'claude-opus-4-1-20250805', label: 'Claude Opus 4.1' }, + { value: 'claude-opus-4-20250514', label: 'Claude Opus 4' }, + { value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' }, + { value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku' } +] + +const GEMINI_MODELS = [ + { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }, + { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, + { value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' }, + { value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' } +] + +const OPENAI_MODELS = [ + { value: 'gpt-5', label: 'GPT-5' }, + { value: 'gpt-5-mini', label: 'GPT-5 Mini' }, + { value: 'gpt-5-nano', label: 'GPT-5 Nano' }, + { value: 'gpt-5.1', label: 'GPT-5.1' }, + { value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' }, + { value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' }, + { value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' }, + { value: 'gpt-5.2', label: 'GPT-5.2' }, + { value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' }, + { value: 'codex-mini', label: 'Codex Mini' } +] + +// 其他模型(用于账户编辑的模型映射) +const OTHER_MODELS = [ + { value: 'deepseek-chat', label: 'DeepSeek Chat' }, + { value: 'Qwen', label: 'Qwen' }, + { value: 'Kimi', label: 'Kimi' }, + { value: 'GLM', label: 'GLM' } +] + +module.exports = { + CLAUDE_MODELS, + GEMINI_MODELS, + OPENAI_MODELS, + OTHER_MODELS, + // 按服务分组 + getModelsByService: (service) => { + switch (service) { + case 'claude': + return CLAUDE_MODELS + case 'gemini': + return GEMINI_MODELS + case 'openai': + return OPENAI_MODELS + default: + return [] + } + }, + // 获取所有模型(用于账户编辑) + getAllModels: () => [...CLAUDE_MODELS, ...GEMINI_MODELS, ...OPENAI_MODELS, ...OTHER_MODELS] +} diff --git a/src/app.js b/src/app.js index 48ec9709..d94f344c 100644 --- a/src/app.js +++ b/src/app.js @@ -52,11 +52,32 @@ class Application { await redis.connect() logger.success('Redis connected successfully') + // 📊 检查数据迁移(版本 > 1.1.250 时执行) + const { getAppVersion, versionGt } = require('./utils/commonHelper') + const currentVersion = getAppVersion() + const migratedVersion = await redis.getMigratedVersion() + if (versionGt(currentVersion, '1.1.250') && versionGt(currentVersion, migratedVersion)) { + logger.info(`🔄 检测到新版本 ${currentVersion},检查数据迁移...`) + try { + if (await redis.needsGlobalStatsMigration()) { + await redis.migrateGlobalStats() + } + await redis.cleanupSystemMetrics() // 清理过期的系统分钟统计 + } catch (err) { + logger.error('⚠️ 数据迁移出错,但不影响启动:', err.message) + } + await redis.setMigratedVersion(currentVersion) + logger.success(`✅ 数据迁移完成,版本: ${currentVersion}`) + } + // 📊 后台异步迁移 usage 索引(不阻塞启动) redis.migrateUsageIndex().catch((err) => { logger.error('📊 Background usage index migration failed:', err) }) + // 📊 迁移 alltime 模型统计(阻塞式,确保数据完整) + await redis.migrateAlltimeModelStats() + // 💰 初始化价格服务 logger.info('🔄 Initializing pricing service...') await pricingService.initialize() diff --git a/src/handlers/geminiHandlers.js b/src/handlers/geminiHandlers.js index 87295d31..ccf0035d 100644 --- a/src/handlers/geminiHandlers.js +++ b/src/handlers/geminiHandlers.js @@ -13,6 +13,7 @@ const crypto = require('crypto') const sessionHelper = require('../utils/sessionHelper') const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler') const apiKeyService = require('../services/apiKeyService') +const redis = require('../models/redis') const { updateRateLimitCounters } = require('../utils/rateLimitHelper') const { parseSSELine } = require('../utils/sseParser') const axios = require('axios') @@ -805,16 +806,18 @@ function handleModelDetails(req, res) { */ async function handleUsage(req, res) { try { - const { usage } = req.apiKey + const keyData = req.apiKey + // 按需查询 usage 数据 + const usage = await redis.getUsageStats(keyData.id) res.json({ object: 'usage', - total_tokens: usage.total.tokens, - total_requests: usage.total.requests, - daily_tokens: usage.daily.tokens, - daily_requests: usage.daily.requests, - monthly_tokens: usage.monthly.tokens, - monthly_requests: usage.monthly.requests + total_tokens: usage?.total?.tokens || 0, + total_requests: usage?.total?.requests || 0, + daily_tokens: usage?.daily?.tokens || 0, + daily_requests: usage?.daily?.requests || 0, + monthly_tokens: usage?.monthly?.tokens || 0, + monthly_requests: usage?.monthly?.requests || 0 }) } catch (error) { logger.error('Failed to get usage stats:', error) @@ -833,17 +836,18 @@ async function handleUsage(req, res) { async function handleKeyInfo(req, res) { try { const keyData = req.apiKey + // 按需查询 usage 数据(仅 key-info 端点需要) + const usage = await redis.getUsageStats(keyData.id) + const tokensUsed = usage?.total?.tokens || 0 res.json({ id: keyData.id, name: keyData.name, permissions: keyData.permissions || 'all', token_limit: keyData.tokenLimit, - tokens_used: keyData.usage.total.tokens, + tokens_used: tokensUsed, tokens_remaining: - keyData.tokenLimit > 0 - ? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens) - : null, + keyData.tokenLimit > 0 ? Math.max(0, keyData.tokenLimit - tokensUsed) : null, rate_limit: { window: keyData.rateLimitWindow, requests: keyData.rateLimitRequests diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 39feb5a8..f7997431 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -1306,10 +1306,8 @@ const authenticateApiKey = async (req, res, next) => { dailyCostLimit: validation.keyData.dailyCostLimit, dailyCost: validation.keyData.dailyCost, totalCostLimit: validation.keyData.totalCostLimit, - totalCost: validation.keyData.totalCost, - usage: validation.keyData.usage + totalCost: validation.keyData.totalCost } - req.usage = validation.keyData.usage const authDuration = Date.now() - startTime const userAgent = req.headers['user-agent'] || 'No User-Agent' diff --git a/src/models/redis.js b/src/models/redis.js index 8ea85a13..0ec47a23 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -299,6 +299,96 @@ class RedisClient { } } + // 🔄 自动迁移 alltime 模型统计(启动时调用) + async migrateAlltimeModelStats() { + const migrationKey = 'system:migration:alltime_model_stats_v1' + const migrated = await this.client.get(migrationKey) + if (migrated) { + logger.debug('📊 Alltime model stats migration already completed') + return + } + + logger.info('📊 Starting alltime model stats migration...') + const stats = { keys: 0, models: 0 } + + try { + // 扫描所有月度模型统计数据并聚合到 alltime + // 格式: usage:{keyId}:model:monthly:{model}:{month} + let cursor = '0' + const aggregatedData = new Map() // keyId:model -> {inputTokens, outputTokens, ...} + + do { + const [newCursor, keys] = await this.client.scan( + cursor, + 'MATCH', + 'usage:*:model:monthly:*:*', + 'COUNT', + 500 + ) + cursor = newCursor + + for (const key of keys) { + // usage:{keyId}:model:monthly:{model}:{month} + const match = key.match(/^usage:([^:]+):model:monthly:(.+):(\d{4}-\d{2})$/) + if (match) { + const [, keyId, model] = match + const aggregateKey = `${keyId}:${model}` + + // 获取该月的数据 + const data = await this.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + if (!aggregatedData.has(aggregateKey)) { + aggregatedData.set(aggregateKey, { + keyId, + model, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + requests: 0 + }) + } + + const agg = aggregatedData.get(aggregateKey) + agg.inputTokens += parseInt(data.inputTokens) || 0 + agg.outputTokens += parseInt(data.outputTokens) || 0 + agg.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0 + agg.cacheReadTokens += parseInt(data.cacheReadTokens) || 0 + agg.requests += parseInt(data.requests) || 0 + stats.keys++ + } + } + } + } while (cursor !== '0') + + // 写入聚合后的 alltime 数据 + const pipeline = this.client.pipeline() + for (const [, agg] of aggregatedData) { + const alltimeKey = `usage:${agg.keyId}:model:alltime:${agg.model}` + pipeline.hset(alltimeKey, { + inputTokens: agg.inputTokens.toString(), + outputTokens: agg.outputTokens.toString(), + cacheCreateTokens: agg.cacheCreateTokens.toString(), + cacheReadTokens: agg.cacheReadTokens.toString(), + requests: agg.requests.toString() + }) + stats.models++ + } + + if (stats.models > 0) { + await pipeline.exec() + } + + // 标记迁移完成 + await this.client.set(migrationKey, Date.now().toString()) + logger.info( + `📊 Alltime model stats migration completed: scanned ${stats.keys} monthly keys, created ${stats.models} alltime keys` + ) + } catch (error) { + logger.error('📊 Alltime model stats migration failed:', error) + } + } + async disconnect() { if (this.client) { await this.client.quit() @@ -996,6 +1086,14 @@ class RedisClient { pipeline.hincrby(keyModelMonthly, 'ephemeral5mTokens', ephemeral5mTokens) pipeline.hincrby(keyModelMonthly, 'ephemeral1hTokens', ephemeral1hTokens) + // API Key级别的模型统计 - 所有时间(无 TTL) + const keyModelAlltime = `usage:${keyId}:model:alltime:${normalizedModel}` + pipeline.hincrby(keyModelAlltime, 'inputTokens', finalInputTokens) + pipeline.hincrby(keyModelAlltime, 'outputTokens', finalOutputTokens) + pipeline.hincrby(keyModelAlltime, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(keyModelAlltime, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(keyModelAlltime, 'requests', 1) + // 小时级别统计 pipeline.hincrby(hourly, 'tokens', coreTokens) pipeline.hincrby(hourly, 'inputTokens', finalInputTokens) @@ -1040,9 +1138,9 @@ class RedisClient { pipeline.expire(keyModelMonthly, 86400 * 365) // API Key模型每月统计1年过期 pipeline.expire(keyModelHourly, 86400 * 7) // API Key模型小时统计7天过期 - // 系统级分钟统计的过期时间(窗口时间的2倍) + // 系统级分钟统计的过期时间(窗口时间的2倍,默认5分钟) const configLocal = require('../../config/config') - const { metricsWindow } = configLocal.system + const metricsWindow = configLocal.system?.metricsWindow || 5 pipeline.expire(systemMinuteKey, metricsWindow * 60 * 2) // 添加索引(用于快速查询,避免 SCAN) @@ -1071,6 +1169,30 @@ class RedisClient { pipeline.expire(`usage:keymodel:daily:index:${today}`, 86400 * 32) pipeline.expire(`usage:keymodel:hourly:index:${currentHour}`, 86400 * 7) + // 全局预聚合统计 + const globalDaily = `usage:global:daily:${today}` + const globalMonthly = `usage:global:monthly:${currentMonth}` + pipeline.hincrby('usage:global:total', 'requests', 1) + pipeline.hincrby('usage:global:total', 'inputTokens', finalInputTokens) + pipeline.hincrby('usage:global:total', 'outputTokens', finalOutputTokens) + pipeline.hincrby('usage:global:total', 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby('usage:global:total', 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby('usage:global:total', 'allTokens', totalTokens) + pipeline.hincrby(globalDaily, 'requests', 1) + pipeline.hincrby(globalDaily, 'inputTokens', finalInputTokens) + pipeline.hincrby(globalDaily, 'outputTokens', finalOutputTokens) + pipeline.hincrby(globalDaily, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(globalDaily, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(globalDaily, 'allTokens', totalTokens) + pipeline.hincrby(globalMonthly, 'requests', 1) + pipeline.hincrby(globalMonthly, 'inputTokens', finalInputTokens) + pipeline.hincrby(globalMonthly, 'outputTokens', finalOutputTokens) + pipeline.hincrby(globalMonthly, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(globalMonthly, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(globalMonthly, 'allTokens', totalTokens) + pipeline.expire(globalDaily, 86400 * 32) + pipeline.expire(globalMonthly, 86400 * 365) + // 执行Pipeline await pipeline.exec() } @@ -4521,4 +4643,151 @@ redisClient.removeFromIndex = async function (indexKey, id) { await client.srem(indexKey, id) } +// ============================================ +// 数据迁移相关 +// ============================================ + +// 迁移全局统计数据(从 API Key 数据聚合) +redisClient.migrateGlobalStats = async function () { + const logger = require('../utils/logger') + logger.info('🔄 开始迁移全局统计数据...') + + const keyIds = await this.scanApiKeyIds() + if (!keyIds || keyIds.length === 0) { + logger.info('📊 没有 API Key 数据需要迁移') + return { success: true, migrated: 0 } + } + + const total = { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0 + } + + // 批量获取所有 usage 数据 + const pipeline = this.client.pipeline() + keyIds.forEach((id) => pipeline.hgetall(`usage:${id}`)) + const results = await pipeline.exec() + + results.forEach(([err, usage]) => { + if (err || !usage) { + return + } + // 兼容新旧字段格式(带 total 前缀和不带的) + total.requests += parseInt(usage.totalRequests || usage.requests) || 0 + total.inputTokens += parseInt(usage.totalInputTokens || usage.inputTokens) || 0 + total.outputTokens += parseInt(usage.totalOutputTokens || usage.outputTokens) || 0 + total.cacheCreateTokens += + parseInt(usage.totalCacheCreateTokens || usage.cacheCreateTokens) || 0 + total.cacheReadTokens += parseInt(usage.totalCacheReadTokens || usage.cacheReadTokens) || 0 + total.allTokens += parseInt(usage.totalAllTokens || usage.allTokens || usage.totalTokens) || 0 + }) + + // 写入全局统计 + await this.client.hset('usage:global:total', total) + logger.success( + `✅ 迁移完成: ${keyIds.length} 个 API Key, ${total.requests} 请求, ${total.allTokens} tokens` + ) + return { success: true, migrated: keyIds.length, total } +} + +// 检查是否需要迁移 +redisClient.needsGlobalStatsMigration = async function () { + const exists = await this.client.exists('usage:global:total') + return exists === 0 +} + +// 获取已迁移版本 +redisClient.getMigratedVersion = async function () { + return (await this.client.get('system:migrated:version')) || '0.0.0' +} + +// 设置已迁移版本 +redisClient.setMigratedVersion = async function (version) { + await this.client.set('system:migrated:version', version) +} + +// 获取全局统计(用于 dashboard 快速查询) +redisClient.getGlobalStats = async function () { + const stats = await this.client.hgetall('usage:global:total') + if (!stats || !stats.requests) { + return null + } + return { + requests: parseInt(stats.requests) || 0, + inputTokens: parseInt(stats.inputTokens) || 0, + outputTokens: parseInt(stats.outputTokens) || 0, + cacheCreateTokens: parseInt(stats.cacheCreateTokens) || 0, + cacheReadTokens: parseInt(stats.cacheReadTokens) || 0, + allTokens: parseInt(stats.allTokens) || 0 + } +} + +// 快速获取 API Key 计数(不拉全量数据) +redisClient.getApiKeyCount = async function () { + const keyIds = await this.scanApiKeyIds() + if (!keyIds || keyIds.length === 0) { + return { total: 0, active: 0 } + } + + // 批量获取 isActive 字段 + const pipeline = this.client.pipeline() + keyIds.forEach((id) => pipeline.hget(`apikey:${id}`, 'isActive')) + const results = await pipeline.exec() + + let active = 0 + results.forEach(([err, val]) => { + if (!err && (val === 'true' || val === true)) { + active++ + } + }) + return { total: keyIds.length, active } +} + +// 清理过期的系统分钟统计数据(启动时调用) +redisClient.cleanupSystemMetrics = async function () { + const logger = require('../utils/logger') + logger.info('🧹 清理过期的系统分钟统计数据...') + + const keys = await this.scanKeys('system:metrics:minute:*') + if (!keys || keys.length === 0) { + logger.info('📊 没有需要清理的系统分钟统计数据') + return { cleaned: 0 } + } + + // 计算当前分钟时间戳和保留窗口 + const config = require('../../config/config') + const metricsWindow = config.system?.metricsWindow || 5 + const currentMinute = Math.floor(Date.now() / 60000) + const keepAfter = currentMinute - metricsWindow * 2 // 保留窗口的2倍 + + // 筛选需要删除的 key + const toDelete = keys.filter((key) => { + const match = key.match(/system:metrics:minute:(\d+)/) + if (!match) { + return false + } + const minute = parseInt(match[1]) + return minute < keepAfter + }) + + if (toDelete.length === 0) { + logger.info('📊 没有过期的系统分钟统计数据') + return { cleaned: 0 } + } + + // 分批删除 + const batchSize = 1000 + for (let i = 0; i < toDelete.length; i += batchSize) { + const batch = toDelete.slice(i, i + batchSize) + await this.client.del(...batch) + } + + logger.success(`✅ 清理完成: 删除 ${toDelete.length} 个过期的系统分钟统计 key`) + return { cleaned: toDelete.length } +} + module.exports = redisClient diff --git a/src/routes/admin/apiKeys.js b/src/routes/admin/apiKeys.js index fa4a2b1c..86484332 100644 --- a/src/routes/admin/apiKeys.js +++ b/src/routes/admin/apiKeys.js @@ -8,6 +8,17 @@ const config = require('../../../config/config') const router = express.Router() +// 有效的服务权限值 +const VALID_SERVICES = ['claude', 'gemini', 'openai', 'droid'] + +// 验证 permissions 值(支持单选和多选) +const isValidPermissions = (permissions) => { + if (!permissions || permissions === 'all') return true + // 支持逗号分隔的多选格式 + const services = permissions.split(',') + return services.every((s) => VALID_SERVICES.includes(s.trim())) +} + // 👥 用户管理 (用于API Key分配) // 获取所有用户列表(用于API Key分配) @@ -1430,10 +1441,10 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { permissions !== undefined && permissions !== null && permissions !== '' && - !['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions) + !isValidPermissions(permissions) ) { return res.status(400).json({ - error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all' + error: 'Invalid permissions value. Must be claude, gemini, openai, droid, all, or comma-separated combination' }) } @@ -1528,10 +1539,10 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { permissions !== undefined && permissions !== null && permissions !== '' && - !['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions) + !isValidPermissions(permissions) ) { return res.status(400).json({ - error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all' + error: 'Invalid permissions value. Must be claude, gemini, openai, droid, all, or comma-separated combination' }) } @@ -1637,10 +1648,10 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => { if ( updates.permissions !== undefined && - !['claude', 'gemini', 'openai', 'droid', 'all'].includes(updates.permissions) + !isValidPermissions(updates.permissions) ) { return res.status(400).json({ - error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all' + error: 'Invalid permissions value. Must be claude, gemini, openai, droid, all, or comma-separated combination' }) } @@ -1917,9 +1928,9 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { if (permissions !== undefined) { // 验证权限值 - if (!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)) { + if (!isValidPermissions(permissions)) { return res.status(400).json({ - error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all' + error: 'Invalid permissions value. Must be claude, gemini, openai, droid, all, or comma-separated combination' }) } updates.permissions = permissions diff --git a/src/routes/admin/azureOpenaiAccounts.js b/src/routes/admin/azureOpenaiAccounts.js index 7a1f8152..bf3ae7e0 100644 --- a/src/routes/admin/azureOpenaiAccounts.js +++ b/src/routes/admin/azureOpenaiAccounts.js @@ -414,4 +414,84 @@ router.post('/migrate-api-keys-azure', authenticateAdmin, async (req, res) => { } }) +// 测试 Azure OpenAI 账户连通性 +router.post('/azure-openai-accounts/:accountId/test', authenticateAdmin, async (req, res) => { + const { accountId } = req.params + const startTime = Date.now() + + try { + // 获取账户信息 + const account = await azureOpenaiAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ error: 'Account not found' }) + } + + // 获取解密后的 API Key + const apiKey = await azureOpenaiAccountService.getDecryptedApiKey(accountId) + if (!apiKey) { + return res.status(401).json({ error: 'API Key not found or decryption failed' }) + } + + // 构造测试请求 + const { createOpenAITestPayload } = require('../../utils/testPayloadHelper') + const { getProxyAgent } = require('../../utils/proxyHelper') + + const deploymentName = account.deploymentName || 'gpt-4o-mini' + const apiVersion = account.apiVersion || '2024-02-15-preview' + const apiUrl = `${account.endpoint}/openai/deployments/${deploymentName}/chat/completions?api-version=${apiVersion}` + const payload = createOpenAITestPayload(deploymentName) + + const requestConfig = { + headers: { + 'Content-Type': 'application/json', + 'api-key': apiKey + }, + timeout: 30000 + } + + // 配置代理 + if (account.proxy) { + const agent = getProxyAgent(account.proxy) + if (agent) { + requestConfig.httpsAgent = agent + requestConfig.httpAgent = agent + } + } + + const response = await axios.post(apiUrl, payload, requestConfig) + const latency = Date.now() - startTime + + // 提取响应文本 + let responseText = '' + if (response.data?.choices?.[0]?.message?.content) { + responseText = response.data.choices[0].message.content + } + + logger.success( + `✅ Azure OpenAI account test passed: ${account.name} (${accountId}), latency: ${latency}ms` + ) + + return res.json({ + success: true, + data: { + accountId, + accountName: account.name, + model: deploymentName, + latency, + responseText: responseText.substring(0, 200) + } + }) + } catch (error) { + const latency = Date.now() - startTime + logger.error(`❌ Azure OpenAI account test failed: ${accountId}`, error.message) + + return res.status(500).json({ + success: false, + error: 'Test failed', + message: error.response?.data?.error?.message || error.message, + latency + }) + } +}) + module.exports = router diff --git a/src/routes/admin/ccrAccounts.js b/src/routes/admin/ccrAccounts.js index fccbca4e..558ba8d3 100644 --- a/src/routes/admin/ccrAccounts.js +++ b/src/routes/admin/ccrAccounts.js @@ -413,4 +413,89 @@ router.post('/reset-all-usage', authenticateAdmin, async (req, res) => { } }) +// 测试 CCR 账户连通性 +router.post('/:accountId/test', authenticateAdmin, async (req, res) => { + const { accountId } = req.params + const { model = 'claude-sonnet-4-20250514' } = req.body + const startTime = Date.now() + + try { + // 获取账户信息 + const account = await ccrAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ error: 'Account not found' }) + } + + // 获取解密后的凭据 + const credentials = await ccrAccountService.getDecryptedCredentials(accountId) + if (!credentials) { + return res.status(401).json({ error: 'Credentials not found or decryption failed' }) + } + + // 构造测试请求 + const axios = require('axios') + const { getProxyAgent } = require('../../utils/proxyHelper') + + const baseUrl = account.baseUrl || 'https://api.anthropic.com' + const apiUrl = `${baseUrl}/v1/messages` + const payload = { + model, + max_tokens: 100, + messages: [{ role: 'user', content: 'Say "Hello" in one word.' }] + } + + const requestConfig = { + headers: { + 'Content-Type': 'application/json', + 'x-api-key': credentials.apiKey, + 'anthropic-version': '2023-06-01' + }, + timeout: 30000 + } + + // 配置代理 + if (account.proxy) { + const agent = getProxyAgent(account.proxy) + if (agent) { + requestConfig.httpsAgent = agent + requestConfig.httpAgent = agent + } + } + + const response = await axios.post(apiUrl, payload, requestConfig) + const latency = Date.now() - startTime + + // 提取响应文本 + let responseText = '' + if (response.data?.content?.[0]?.text) { + responseText = response.data.content[0].text + } + + logger.success( + `✅ CCR account test passed: ${account.name} (${accountId}), latency: ${latency}ms` + ) + + return res.json({ + success: true, + data: { + accountId, + accountName: account.name, + model, + latency, + responseText: responseText.substring(0, 200) + } + }) + } catch (error) { + const latency = Date.now() - startTime + logger.error(`❌ CCR account test failed: ${accountId}`, error.message) + + return res.status(500).json({ + success: false, + error: 'Test failed', + message: error.response?.data?.error?.message || error.message, + latency + }) + } +}) + module.exports = router diff --git a/src/routes/admin/dashboard.js b/src/routes/admin/dashboard.js index 8ba2233d..fb47f98e 100644 --- a/src/routes/admin/dashboard.js +++ b/src/routes/admin/dashboard.js @@ -20,8 +20,14 @@ const router = express.Router() // 获取系统概览 router.get('/dashboard', authenticateAdmin, async (req, res) => { try { + // 先检查是否有全局预聚合数据 + const globalStats = await redis.getGlobalStats() + + // 根据是否有全局统计决定查询策略 + let apiKeys = null + let apiKeyCount = null + const [ - apiKeys, claudeAccounts, claudeConsoleAccounts, geminiAccounts, @@ -34,7 +40,6 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => { systemAverages, realtimeMetrics ] = await Promise.all([ - apiKeyService.getAllApiKeysFast(), claudeAccountService.getAllAccounts(), claudeConsoleAccountService.getAllAccounts(), geminiAccountService.getAllAccounts(), @@ -48,6 +53,13 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => { redis.getRealtimeSystemMetrics() ]) + // 有全局统计时只获取计数,否则拉全量 + if (globalStats) { + apiKeyCount = await redis.getApiKeyCount() + } else { + apiKeys = await apiKeyService.getAllApiKeysFast() + } + // 处理Bedrock账户数据 const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : [] const normalizeBoolean = (value) => value === true || value === 'true' @@ -122,7 +134,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => { } } - // 计算使用统计(单次遍历) + // 计算使用统计 let totalTokensUsed = 0, totalRequestsUsed = 0, totalInputTokensUsed = 0, @@ -130,20 +142,37 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => { totalCacheCreateTokensUsed = 0, totalCacheReadTokensUsed = 0, totalAllTokensUsed = 0, - activeApiKeys = 0 - for (const key of apiKeys) { - const usage = key.usage?.total - if (usage) { - totalTokensUsed += usage.allTokens || 0 - totalRequestsUsed += usage.requests || 0 - totalInputTokensUsed += usage.inputTokens || 0 - totalOutputTokensUsed += usage.outputTokens || 0 - totalCacheCreateTokensUsed += usage.cacheCreateTokens || 0 - totalCacheReadTokensUsed += usage.cacheReadTokens || 0 - totalAllTokensUsed += usage.allTokens || 0 - } - if (key.isActive) { - activeApiKeys++ + activeApiKeys = 0, + totalApiKeys = 0 + + if (globalStats) { + // 使用预聚合数据(快速路径) + totalRequestsUsed = globalStats.requests + totalInputTokensUsed = globalStats.inputTokens + totalOutputTokensUsed = globalStats.outputTokens + totalCacheCreateTokensUsed = globalStats.cacheCreateTokens + totalCacheReadTokensUsed = globalStats.cacheReadTokens + totalAllTokensUsed = globalStats.allTokens + totalTokensUsed = totalAllTokensUsed + totalApiKeys = apiKeyCount.total + activeApiKeys = apiKeyCount.active + } else { + // 回退到遍历(兼容旧数据) + totalApiKeys = apiKeys.length + for (const key of apiKeys) { + const usage = key.usage?.total + if (usage) { + totalTokensUsed += usage.allTokens || 0 + totalRequestsUsed += usage.requests || 0 + totalInputTokensUsed += usage.inputTokens || 0 + totalOutputTokensUsed += usage.outputTokens || 0 + totalCacheCreateTokensUsed += usage.cacheCreateTokens || 0 + totalCacheReadTokensUsed += usage.cacheReadTokens || 0 + totalAllTokensUsed += usage.allTokens || 0 + } + if (key.isActive) { + activeApiKeys++ + } } } @@ -158,7 +187,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => { const dashboard = { overview: { - totalApiKeys: apiKeys.length, + totalApiKeys, activeApiKeys, // 总账户统计(所有平台) totalAccounts: diff --git a/src/routes/admin/droidAccounts.js b/src/routes/admin/droidAccounts.js index 90a44550..631269b8 100644 --- a/src/routes/admin/droidAccounts.js +++ b/src/routes/admin/droidAccounts.js @@ -196,31 +196,56 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => { // 处理统计数据 const allUsageStatsMap = new Map() + const parseUsage = (data) => ({ + requests: parseInt(data?.totalRequests || data?.requests) || 0, + tokens: parseInt(data?.totalTokens || data?.tokens) || 0, + inputTokens: parseInt(data?.totalInputTokens || data?.inputTokens) || 0, + outputTokens: parseInt(data?.totalOutputTokens || data?.outputTokens) || 0, + cacheCreateTokens: parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0, + cacheReadTokens: parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0, + allTokens: + parseInt(data?.totalAllTokens || data?.allTokens) || + (parseInt(data?.totalInputTokens || data?.inputTokens) || 0) + + (parseInt(data?.totalOutputTokens || data?.outputTokens) || 0) + + (parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0) + + (parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0) + }) + + // 构建 accountId -> createdAt 映射用于计算 averages + const accountCreatedAtMap = new Map() + for (const account of accounts) { + accountCreatedAtMap.set( + account.id, + account.createdAt ? new Date(account.createdAt) : new Date() + ) + } + for (let i = 0; i < accountIds.length; i++) { const accountId = accountIds[i] const [errTotal, total] = statsResults[i * 3] const [errDaily, daily] = statsResults[i * 3 + 1] const [errMonthly, monthly] = statsResults[i * 3 + 2] - const parseUsage = (data) => ({ - requests: parseInt(data?.totalRequests || data?.requests) || 0, - tokens: parseInt(data?.totalTokens || data?.tokens) || 0, - inputTokens: parseInt(data?.totalInputTokens || data?.inputTokens) || 0, - outputTokens: parseInt(data?.totalOutputTokens || data?.outputTokens) || 0, - cacheCreateTokens: parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0, - cacheReadTokens: parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0, - allTokens: - parseInt(data?.totalAllTokens || data?.allTokens) || - (parseInt(data?.totalInputTokens || data?.inputTokens) || 0) + - (parseInt(data?.totalOutputTokens || data?.outputTokens) || 0) + - (parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0) + - (parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0) - }) + const totalData = errTotal ? {} : parseUsage(total) + const totalTokens = totalData.tokens || 0 + const totalRequests = totalData.requests || 0 + + // 计算 averages + const createdAt = accountCreatedAtMap.get(accountId) + const now = new Date() + const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24))) + const totalMinutes = Math.max(1, daysSinceCreated * 24 * 60) allUsageStatsMap.set(accountId, { - total: errTotal ? {} : parseUsage(total), + total: totalData, daily: errDaily ? {} : parseUsage(daily), - monthly: errMonthly ? {} : parseUsage(monthly) + monthly: errMonthly ? {} : parseUsage(monthly), + averages: { + rpm: Math.round((totalRequests / totalMinutes) * 100) / 100, + tpm: Math.round((totalTokens / totalMinutes) * 100) / 100, + dailyRequests: Math.round((totalRequests / daysSinceCreated) * 100) / 100, + dailyTokens: Math.round((totalTokens / daysSinceCreated) * 100) / 100 + } }) } @@ -230,7 +255,8 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => { const usageStats = allUsageStatsMap.get(account.id) || { daily: { tokens: 0, requests: 0 }, total: { tokens: 0, requests: 0 }, - monthly: { tokens: 0, requests: 0 } + monthly: { tokens: 0, requests: 0 }, + averages: { rpm: 0, tpm: 0, dailyRequests: 0, dailyTokens: 0 } } const dailyCost = dailyCostMap.get(account.id) || 0 @@ -249,7 +275,8 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => { usage: { daily: { ...usageStats.daily, cost: dailyCost }, total: usageStats.total, - monthly: usageStats.monthly + monthly: usageStats.monthly, + averages: usageStats.averages } } }) @@ -574,4 +601,92 @@ router.post('/droid-accounts/:id/refresh-token', authenticateAdmin, async (req, } }) +// 测试 Droid 账户连通性 +router.post('/droid-accounts/:accountId/test', authenticateAdmin, async (req, res) => { + const { accountId } = req.params + const { model = 'claude-sonnet-4-20250514' } = req.body + const startTime = Date.now() + + try { + // 获取账户信息 + const account = await droidAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ error: 'Account not found' }) + } + + // 确保 token 有效 + const tokenResult = await droidAccountService.ensureValidToken(accountId) + if (!tokenResult.success) { + return res.status(401).json({ + error: 'Token refresh failed', + message: tokenResult.error + }) + } + + const accessToken = tokenResult.accessToken + + // 构造测试请求 + const axios = require('axios') + const { getProxyAgent } = require('../../utils/proxyHelper') + + const apiUrl = 'https://api.factory.ai/v1/messages' + const payload = { + model, + max_tokens: 100, + messages: [{ role: 'user', content: 'Say "Hello" in one word.' }] + } + + const requestConfig = { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}` + }, + timeout: 30000 + } + + // 配置代理 + if (account.proxy) { + const agent = getProxyAgent(account.proxy) + if (agent) { + requestConfig.httpsAgent = agent + requestConfig.httpAgent = agent + } + } + + const response = await axios.post(apiUrl, payload, requestConfig) + const latency = Date.now() - startTime + + // 提取响应文本 + let responseText = '' + if (response.data?.content?.[0]?.text) { + responseText = response.data.content[0].text + } + + logger.success( + `✅ Droid account test passed: ${account.name} (${accountId}), latency: ${latency}ms` + ) + + return res.json({ + success: true, + data: { + accountId, + accountName: account.name, + model, + latency, + responseText: responseText.substring(0, 200) + } + }) + } catch (error) { + const latency = Date.now() - startTime + logger.error(`❌ Droid account test failed: ${accountId}`, error.message) + + return res.status(500).json({ + success: false, + error: 'Test failed', + message: error.response?.data?.error?.message || error.message, + latency + }) + } +}) + module.exports = router diff --git a/src/routes/admin/geminiAccounts.js b/src/routes/admin/geminiAccounts.js index b2fee5e7..3d9c4abf 100644 --- a/src/routes/admin/geminiAccounts.js +++ b/src/routes/admin/geminiAccounts.js @@ -491,4 +491,89 @@ router.post('/:id/reset-status', authenticateAdmin, async (req, res) => { } }) +// 测试 Gemini 账户连通性 +router.post('/:accountId/test', authenticateAdmin, async (req, res) => { + const { accountId } = req.params + const { model = 'gemini-2.5-flash' } = req.body + const startTime = Date.now() + + try { + // 获取账户信息 + const account = await geminiAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ error: 'Account not found' }) + } + + // 确保 token 有效 + const tokenResult = await geminiAccountService.ensureValidToken(accountId) + if (!tokenResult.success) { + return res.status(401).json({ + error: 'Token refresh failed', + message: tokenResult.error + }) + } + + const accessToken = tokenResult.accessToken + + // 构造测试请求 + const axios = require('axios') + const { createGeminiTestPayload } = require('../../utils/testPayloadHelper') + const { getProxyAgent } = require('../../utils/proxyHelper') + + const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent` + const payload = createGeminiTestPayload(model) + + const requestConfig = { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}` + }, + timeout: 30000 + } + + // 配置代理 + if (account.proxy) { + const agent = getProxyAgent(account.proxy) + if (agent) { + requestConfig.httpsAgent = agent + requestConfig.httpAgent = agent + } + } + + const response = await axios.post(apiUrl, payload, requestConfig) + const latency = Date.now() - startTime + + // 提取响应文本 + let responseText = '' + if (response.data?.candidates?.[0]?.content?.parts?.[0]?.text) { + responseText = response.data.candidates[0].content.parts[0].text + } + + logger.success( + `✅ Gemini account test passed: ${account.name} (${accountId}), latency: ${latency}ms` + ) + + return res.json({ + success: true, + data: { + accountId, + accountName: account.name, + model, + latency, + responseText: responseText.substring(0, 200) + } + }) + } catch (error) { + const latency = Date.now() - startTime + logger.error(`❌ Gemini account test failed: ${accountId}`, error.message) + + return res.status(500).json({ + success: false, + error: 'Test failed', + message: error.response?.data?.error?.message || error.message, + latency + }) + } +}) + module.exports = router diff --git a/src/routes/admin/index.js b/src/routes/admin/index.js index 0b8cbecd..bb607e9d 100644 --- a/src/routes/admin/index.js +++ b/src/routes/admin/index.js @@ -25,6 +25,8 @@ const systemRoutes = require('./system') const concurrencyRoutes = require('./concurrency') const claudeRelayConfigRoutes = require('./claudeRelayConfig') const syncRoutes = require('./sync') +const serviceRatesRoutes = require('./serviceRates') +const quotaCardsRoutes = require('./quotaCards') // 挂载所有子路由 // 使用完整路径的模块(直接挂载到根路径) @@ -41,6 +43,8 @@ router.use('/', systemRoutes) router.use('/', concurrencyRoutes) router.use('/', claudeRelayConfigRoutes) router.use('/', syncRoutes) +router.use('/', serviceRatesRoutes) +router.use('/', quotaCardsRoutes) // 使用相对路径的模块(需要指定基础路径前缀) router.use('/account-groups', accountGroupsRoutes) diff --git a/src/routes/admin/openaiResponsesAccounts.js b/src/routes/admin/openaiResponsesAccounts.js index 0fff3196..3c7f91a0 100644 --- a/src/routes/admin/openaiResponsesAccounts.js +++ b/src/routes/admin/openaiResponsesAccounts.js @@ -452,4 +452,85 @@ router.post('/openai-responses-accounts/:id/reset-usage', authenticateAdmin, asy } }) +// 测试 OpenAI-Responses 账户连通性 +router.post('/openai-responses-accounts/:accountId/test', authenticateAdmin, async (req, res) => { + const { accountId } = req.params + const { model = 'gpt-4o-mini' } = req.body + const startTime = Date.now() + + try { + // 获取账户信息 + const account = await openaiResponsesAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ error: 'Account not found' }) + } + + // 获取解密后的 API Key + const apiKey = await openaiResponsesAccountService.getDecryptedApiKey(accountId) + if (!apiKey) { + return res.status(401).json({ error: 'API Key not found or decryption failed' }) + } + + // 构造测试请求 + const axios = require('axios') + const { createOpenAITestPayload } = require('../../utils/testPayloadHelper') + const { getProxyAgent } = require('../../utils/proxyHelper') + + const baseUrl = account.baseUrl || 'https://api.openai.com' + const apiUrl = `${baseUrl}/v1/chat/completions` + const payload = createOpenAITestPayload(model) + + const requestConfig = { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}` + }, + timeout: 30000 + } + + // 配置代理 + if (account.proxy) { + const agent = getProxyAgent(account.proxy) + if (agent) { + requestConfig.httpsAgent = agent + requestConfig.httpAgent = agent + } + } + + const response = await axios.post(apiUrl, payload, requestConfig) + const latency = Date.now() - startTime + + // 提取响应文本 + let responseText = '' + if (response.data?.choices?.[0]?.message?.content) { + responseText = response.data.choices[0].message.content + } + + logger.success( + `✅ OpenAI-Responses account test passed: ${account.name} (${accountId}), latency: ${latency}ms` + ) + + return res.json({ + success: true, + data: { + accountId, + accountName: account.name, + model, + latency, + responseText: responseText.substring(0, 200) + } + }) + } catch (error) { + const latency = Date.now() - startTime + logger.error(`❌ OpenAI-Responses account test failed: ${accountId}`, error.message) + + return res.status(500).json({ + success: false, + error: 'Test failed', + message: error.response?.data?.error?.message || error.message, + latency + }) + } +}) + module.exports = router diff --git a/src/routes/admin/quotaCards.js b/src/routes/admin/quotaCards.js new file mode 100644 index 00000000..ab69420a --- /dev/null +++ b/src/routes/admin/quotaCards.js @@ -0,0 +1,329 @@ +/** + * 额度卡/时间卡管理路由 + */ +const express = require('express') +const router = express.Router() +const quotaCardService = require('../../services/quotaCardService') +const apiKeyService = require('../../services/apiKeyService') +const logger = require('../../utils/logger') +const { authenticateAdmin } = require('../../middleware/auth') + +// ═══════════════════════════════════════════════════════════════════════════ +// 额度卡管理 +// ═══════════════════════════════════════════════════════════════════════════ + +// 获取额度卡列表 +router.get('/quota-cards', authenticateAdmin, async (req, res) => { + try { + const { status, limit = 100, offset = 0 } = req.query + const result = await quotaCardService.getAllCards({ + status, + limit: parseInt(limit), + offset: parseInt(offset) + }) + + res.json({ + success: true, + data: result + }) + } catch (error) { + logger.error('❌ Failed to get quota cards:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 获取额度卡统计 +router.get('/quota-cards/stats', authenticateAdmin, async (req, res) => { + try { + const stats = await quotaCardService.getCardStats() + res.json({ + success: true, + data: stats + }) + } catch (error) { + logger.error('❌ Failed to get quota card stats:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 获取单个额度卡详情 +router.get('/quota-cards/:id', authenticateAdmin, async (req, res) => { + try { + const card = await quotaCardService.getCardById(req.params.id) + if (!card) { + return res.status(404).json({ + success: false, + error: 'Card not found' + }) + } + + res.json({ + success: true, + data: card + }) + } catch (error) { + logger.error('❌ Failed to get quota card:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 创建额度卡 +router.post('/quota-cards', authenticateAdmin, async (req, res) => { + try { + const { type, quotaAmount, timeAmount, timeUnit, expiresAt, note, count = 1 } = req.body + + if (!type) { + return res.status(400).json({ + success: false, + error: 'type is required' + }) + } + + const createdBy = req.session?.username || 'admin' + const options = { + type, + quotaAmount: parseFloat(quotaAmount || 0), + timeAmount: parseInt(timeAmount || 0), + timeUnit: timeUnit || 'days', + expiresAt, + note, + createdBy + } + + let result + if (count > 1) { + result = await quotaCardService.createCardsBatch(options, Math.min(count, 100)) + } else { + result = await quotaCardService.createCard(options) + } + + res.json({ + success: true, + data: result + }) + } catch (error) { + logger.error('❌ Failed to create quota card:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 删除未使用的额度卡 +router.delete('/quota-cards/:id', authenticateAdmin, async (req, res) => { + try { + const result = await quotaCardService.deleteCard(req.params.id) + res.json({ + success: true, + data: result + }) + } catch (error) { + logger.error('❌ Failed to delete quota card:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 核销记录管理 +// ═══════════════════════════════════════════════════════════════════════════ + +// 获取核销记录列表 +router.get('/redemptions', authenticateAdmin, async (req, res) => { + try { + const { userId, apiKeyId, limit = 100, offset = 0 } = req.query + const result = await quotaCardService.getRedemptions({ + userId, + apiKeyId, + limit: parseInt(limit), + offset: parseInt(offset) + }) + + res.json({ + success: true, + data: result + }) + } catch (error) { + logger.error('❌ Failed to get redemptions:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 撤销核销 +router.post('/redemptions/:id/revoke', authenticateAdmin, async (req, res) => { + try { + const { reason } = req.body + const revokedBy = req.session?.username || 'admin' + + const result = await quotaCardService.revokeRedemption(req.params.id, revokedBy, reason) + + res.json({ + success: true, + data: result + }) + } catch (error) { + logger.error('❌ Failed to revoke redemption:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// API Key 聚合类型转换 +// ═══════════════════════════════════════════════════════════════════════════ + +// 获取转换预览 +router.get('/api-keys/:id/convert-preview', authenticateAdmin, async (req, res) => { + try { + const preview = await apiKeyService.getConvertToAggregatedPreview(req.params.id) + res.json({ + success: true, + data: preview + }) + } catch (error) { + logger.error('❌ Failed to get convert preview:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 执行转换 +router.post('/api-keys/:id/convert-to-aggregated', authenticateAdmin, async (req, res) => { + try { + const { quotaLimit, permissions, serviceQuotaLimits, quotaUsed } = req.body + + if (quotaLimit === undefined || quotaLimit === null) { + return res.status(400).json({ + success: false, + error: 'quotaLimit is required' + }) + } + + if (!permissions || !Array.isArray(permissions) || permissions.length === 0) { + return res.status(400).json({ + success: false, + error: 'permissions must be a non-empty array' + }) + } + + const result = await apiKeyService.convertToAggregated(req.params.id, { + quotaLimit: parseFloat(quotaLimit), + permissions, + serviceQuotaLimits: serviceQuotaLimits || {}, + quotaUsed: quotaUsed !== undefined ? parseFloat(quotaUsed) : null + }) + + res.json({ + success: true, + data: result + }) + } catch (error) { + logger.error('❌ Failed to convert to aggregated:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 手动增加额度 +router.post('/api-keys/:id/add-quota', authenticateAdmin, async (req, res) => { + try { + const { quotaAmount } = req.body + + if (!quotaAmount || quotaAmount <= 0) { + return res.status(400).json({ + success: false, + error: 'quotaAmount must be a positive number' + }) + } + + const result = await apiKeyService.addQuota(req.params.id, parseFloat(quotaAmount)) + + res.json({ + success: true, + data: result + }) + } catch (error) { + logger.error('❌ Failed to add quota:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 手动减少额度 +router.post('/api-keys/:id/deduct-quota', authenticateAdmin, async (req, res) => { + try { + const { quotaAmount } = req.body + + if (!quotaAmount || quotaAmount <= 0) { + return res.status(400).json({ + success: false, + error: 'quotaAmount must be a positive number' + }) + } + + const result = await apiKeyService.deductQuotaLimit(req.params.id, parseFloat(quotaAmount)) + + res.json({ + success: true, + data: result + }) + } catch (error) { + logger.error('❌ Failed to deduct quota:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 延长有效期 +router.post('/api-keys/:id/extend-expiry', authenticateAdmin, async (req, res) => { + try { + const { amount, unit = 'days' } = req.body + + if (!amount || amount <= 0) { + return res.status(400).json({ + success: false, + error: 'amount must be a positive number' + }) + } + + const result = await apiKeyService.extendExpiry(req.params.id, parseInt(amount), unit) + + res.json({ + success: true, + data: result + }) + } catch (error) { + logger.error('❌ Failed to extend expiry:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +module.exports = router diff --git a/src/routes/admin/serviceRates.js b/src/routes/admin/serviceRates.js new file mode 100644 index 00000000..fa8cdaf1 --- /dev/null +++ b/src/routes/admin/serviceRates.js @@ -0,0 +1,72 @@ +/** + * 服务倍率配置管理路由 + */ +const express = require('express') +const router = express.Router() +const serviceRatesService = require('../../services/serviceRatesService') +const logger = require('../../utils/logger') +const { authenticateAdmin } = require('../../middleware/auth') + +// 获取服务倍率配置 +router.get('/service-rates', authenticateAdmin, async (req, res) => { + try { + const rates = await serviceRatesService.getRates() + res.json({ + success: true, + data: rates + }) + } catch (error) { + logger.error('❌ Failed to get service rates:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 更新服务倍率配置 +router.put('/service-rates', authenticateAdmin, async (req, res) => { + try { + const { rates, baseService } = req.body + + if (!rates || typeof rates !== 'object') { + return res.status(400).json({ + success: false, + error: 'rates is required and must be an object' + }) + } + + const updatedBy = req.session?.username || 'admin' + const result = await serviceRatesService.saveRates({ rates, baseService }, updatedBy) + + res.json({ + success: true, + data: result + }) + } catch (error) { + logger.error('❌ Failed to update service rates:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 获取可用服务列表 +router.get('/service-rates/services', authenticateAdmin, async (req, res) => { + try { + const services = await serviceRatesService.getAvailableServices() + res.json({ + success: true, + data: services + }) + } catch (error) { + logger.error('❌ Failed to get available services:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +module.exports = router diff --git a/src/routes/admin/system.js b/src/routes/admin/system.js index 5692103c..6304c86d 100644 --- a/src/routes/admin/system.js +++ b/src/routes/admin/system.js @@ -267,6 +267,11 @@ router.get('/oem-settings', async (req, res) => { siteIcon: '', siteIconData: '', // Base64编码的图标数据 showAdminButton: true, // 是否显示管理后台按钮 + apiStatsNotice: { + enabled: false, + title: '', + content: '' + }, updatedAt: new Date().toISOString() } @@ -296,7 +301,7 @@ router.get('/oem-settings', async (req, res) => { // 更新OEM设置 router.put('/oem-settings', authenticateAdmin, async (req, res) => { try { - const { siteName, siteIcon, siteIconData, showAdminButton } = req.body + const { siteName, siteIcon, siteIconData, showAdminButton, apiStatsNotice } = req.body // 验证输入 if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) { @@ -328,6 +333,11 @@ router.put('/oem-settings', authenticateAdmin, async (req, res) => { siteIcon: (siteIcon || '').trim(), siteIconData: (siteIconData || '').trim(), // Base64数据 showAdminButton: showAdminButton !== false, // 默认为true + apiStatsNotice: { + enabled: apiStatsNotice?.enabled === true, + title: (apiStatsNotice?.title || '').trim().slice(0, 100), + content: (apiStatsNotice?.content || '').trim().slice(0, 2000) + }, updatedAt: new Date().toISOString() } diff --git a/src/routes/admin/usageStats.js b/src/routes/admin/usageStats.js index 4c3eaf4a..69145d93 100644 --- a/src/routes/admin/usageStats.js +++ b/src/routes/admin/usageStats.js @@ -77,7 +77,14 @@ async function getUsageDataByIndex(indexKey, keyPattern, scanPattern) { return `${match[1]}:${match[2]}` } } - // 通用格式:提取最后一个 : 前的 id + // 通用格式:根据 keyPattern 中 {id} 的位置提取 id + const patternParts = keyPattern.split(':') + const idIndex = patternParts.findIndex((p) => p === '{id}') + if (idIndex !== -1) { + const parts = k.split(':') + return parts[idIndex] + } + // 回退:提取最后一个 : 前的 id const parts = k.split(':') return parts[parts.length - 2] }) diff --git a/src/routes/apiStats.js b/src/routes/apiStats.js index 23c6d94a..a4d76368 100644 --- a/src/routes/apiStats.js +++ b/src/routes/apiStats.js @@ -5,10 +5,38 @@ const apiKeyService = require('../services/apiKeyService') const CostCalculator = require('../utils/costCalculator') const claudeAccountService = require('../services/claudeAccountService') const openaiAccountService = require('../services/openaiAccountService') +const serviceRatesService = require('../services/serviceRatesService') const { createClaudeTestPayload } = require('../utils/testPayloadHelper') +const modelsConfig = require('../../config/models') const router = express.Router() +// 📋 获取可用模型列表(公开接口) +router.get('/models', (req, res) => { + const { service } = req.query + + if (service) { + // 返回指定服务的模型 + const models = modelsConfig.getModelsByService(service) + return res.json({ + success: true, + data: models + }) + } + + // 返回所有模型(按服务分组) + res.json({ + success: true, + data: { + claude: modelsConfig.CLAUDE_MODELS, + gemini: modelsConfig.GEMINI_MODELS, + openai: modelsConfig.OPENAI_MODELS, + other: modelsConfig.OTHER_MODELS, + all: modelsConfig.getAllModels() + } + }) +}) + // 🏠 重定向页面请求到新版 admin-spa router.get('/', (req, res) => { res.redirect(301, '/admin-next/api-stats') @@ -800,13 +828,19 @@ router.post('/api/batch-model-stats', async (req, res) => { } }) +// maxTokens 白名单 +const ALLOWED_MAX_TOKENS = [100, 500, 1000, 2000, 4096] +const sanitizeMaxTokens = (value) => + ALLOWED_MAX_TOKENS.includes(Number(value)) ? Number(value) : 1000 + // 🧪 API Key 端点测试接口 - 测试API Key是否能正常访问服务 router.post('/api-key/test', async (req, res) => { const config = require('../../config/config') const { sendStreamTestRequest } = require('../utils/testPayloadHelper') try { - const { apiKey, model = 'claude-sonnet-4-5-20250929' } = req.body + const { apiKey, model = 'claude-sonnet-4-5-20250929', prompt = 'hi' } = req.body + const maxTokens = sanitizeMaxTokens(req.body.maxTokens) if (!apiKey) { return res.status(400).json({ @@ -839,7 +873,7 @@ router.post('/api-key/test', async (req, res) => { apiUrl, authorization: apiKey, responseStream: res, - payload: createClaudeTestPayload(model, { stream: true }), + payload: createClaudeTestPayload(model, { stream: true, prompt, maxTokens }), timeout: 60000, extraHeaders: { 'x-api-key': apiKey } }) @@ -860,6 +894,318 @@ router.post('/api-key/test', async (req, res) => { } }) +// 🧪 Gemini API Key 端点测试接口 +router.post('/api-key/test-gemini', async (req, res) => { + const config = require('../../config/config') + const { createGeminiTestPayload } = require('../utils/testPayloadHelper') + + try { + const { apiKey, model = 'gemini-2.5-pro', prompt = 'hi' } = req.body + const maxTokens = sanitizeMaxTokens(req.body.maxTokens) + + if (!apiKey) { + return res.status(400).json({ + error: 'API Key is required', + message: 'Please provide your API Key' + }) + } + + if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) { + return res.status(400).json({ + error: 'Invalid API key format', + message: 'API key format is invalid' + }) + } + + const validation = await apiKeyService.validateApiKeyForStats(apiKey) + if (!validation.valid) { + return res.status(401).json({ + error: 'Invalid API key', + message: validation.error + }) + } + + // 检查 Gemini 权限 + const permissions = validation.keyData.permissions || 'all' + if (permissions !== 'all' && !permissions.includes('gemini')) { + return res.status(403).json({ + error: 'Permission denied', + message: 'This API key does not have Gemini permission' + }) + } + + logger.api( + `🧪 Gemini API Key test started for: ${validation.keyData.name} (${validation.keyData.id})` + ) + + const port = config.server.port || 3000 + const apiUrl = `http://127.0.0.1:${port}/gemini/v1/models/${model}:streamGenerateContent?alt=sse` + + // 设置 SSE 响应头 + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no' + }) + + res.write(`data: ${JSON.stringify({ type: 'test_start', message: 'Test started' })}\n\n`) + + const axios = require('axios') + const payload = createGeminiTestPayload(model, { prompt, maxTokens }) + + try { + const response = await axios.post(apiUrl, payload, { + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey + }, + timeout: 60000, + responseType: 'stream', + validateStatus: () => true + }) + + if (response.status !== 200) { + const chunks = [] + response.data.on('data', (chunk) => chunks.push(chunk)) + response.data.on('end', () => { + const errorData = Buffer.concat(chunks).toString() + let errorMsg = `API Error: ${response.status}` + try { + const json = JSON.parse(errorData) + errorMsg = json.message || json.error?.message || json.error || errorMsg + } catch { + if (errorData.length < 200) { + errorMsg = errorData || errorMsg + } + } + res.write( + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMsg })}\n\n` + ) + res.end() + }) + return + } + + let buffer = '' + response.data.on('data', (chunk) => { + buffer += chunk.toString() + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (!line.startsWith('data:')) { + continue + } + const jsonStr = line.substring(5).trim() + if (!jsonStr || jsonStr === '[DONE]') { + continue + } + + try { + const data = JSON.parse(jsonStr) + // Gemini 格式: candidates[0].content.parts[0].text + const text = data.candidates?.[0]?.content?.parts?.[0]?.text + if (text) { + res.write(`data: ${JSON.stringify({ type: 'content', text })}\n\n`) + } + } catch { + // ignore + } + } + }) + + response.data.on('end', () => { + res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`) + res.end() + }) + + response.data.on('error', (err) => { + res.write( + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: err.message })}\n\n` + ) + res.end() + }) + } catch (axiosError) { + res.write( + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: axiosError.message })}\n\n` + ) + res.end() + } + } catch (error) { + logger.error('❌ Gemini API Key test failed:', error) + + if (!res.headersSent) { + return res.status(500).json({ + error: 'Test failed', + message: error.message || 'Internal server error' + }) + } + + res.write( + `data: ${JSON.stringify({ type: 'error', error: error.message || 'Test failed' })}\n\n` + ) + res.end() + } +}) + +// 🧪 OpenAI/Codex API Key 端点测试接口 +router.post('/api-key/test-openai', async (req, res) => { + const config = require('../../config/config') + const { createOpenAITestPayload } = require('../utils/testPayloadHelper') + + try { + const { apiKey, model = 'gpt-5', prompt = 'hi' } = req.body + const maxTokens = sanitizeMaxTokens(req.body.maxTokens) + + if (!apiKey) { + return res.status(400).json({ + error: 'API Key is required', + message: 'Please provide your API Key' + }) + } + + if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) { + return res.status(400).json({ + error: 'Invalid API key format', + message: 'API key format is invalid' + }) + } + + const validation = await apiKeyService.validateApiKeyForStats(apiKey) + if (!validation.valid) { + return res.status(401).json({ + error: 'Invalid API key', + message: validation.error + }) + } + + // 检查 OpenAI 权限 + const permissions = validation.keyData.permissions || 'all' + if (permissions !== 'all' && !permissions.includes('openai')) { + return res.status(403).json({ + error: 'Permission denied', + message: 'This API key does not have OpenAI permission' + }) + } + + logger.api( + `🧪 OpenAI API Key test started for: ${validation.keyData.name} (${validation.keyData.id})` + ) + + const port = config.server.port || 3000 + const apiUrl = `http://127.0.0.1:${port}/openai/responses` + + // 设置 SSE 响应头 + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no' + }) + + res.write(`data: ${JSON.stringify({ type: 'test_start', message: 'Test started' })}\n\n`) + + const axios = require('axios') + const payload = createOpenAITestPayload(model, { prompt, maxTokens }) + + try { + const response = await axios.post(apiUrl, payload, { + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'User-Agent': 'codex_cli_rs/1.0.0' + }, + timeout: 60000, + responseType: 'stream', + validateStatus: () => true + }) + + if (response.status !== 200) { + const chunks = [] + response.data.on('data', (chunk) => chunks.push(chunk)) + response.data.on('end', () => { + const errorData = Buffer.concat(chunks).toString() + let errorMsg = `API Error: ${response.status}` + try { + const json = JSON.parse(errorData) + errorMsg = json.message || json.error?.message || json.error || errorMsg + } catch { + if (errorData.length < 200) { + errorMsg = errorData || errorMsg + } + } + res.write( + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMsg })}\n\n` + ) + res.end() + }) + return + } + + let buffer = '' + response.data.on('data', (chunk) => { + buffer += chunk.toString() + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (!line.startsWith('data:')) { + continue + } + const jsonStr = line.substring(5).trim() + if (!jsonStr || jsonStr === '[DONE]') { + continue + } + + try { + const data = JSON.parse(jsonStr) + // OpenAI Responses 格式: output[].content[].text 或 delta + if (data.type === 'response.output_text.delta' && data.delta) { + res.write(`data: ${JSON.stringify({ type: 'content', text: data.delta })}\n\n`) + } else if (data.type === 'response.content_part.delta' && data.delta?.text) { + res.write(`data: ${JSON.stringify({ type: 'content', text: data.delta.text })}\n\n`) + } + } catch { + // ignore + } + } + }) + + response.data.on('end', () => { + res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`) + res.end() + }) + + response.data.on('error', (err) => { + res.write( + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: err.message })}\n\n` + ) + res.end() + }) + } catch (axiosError) { + res.write( + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: axiosError.message })}\n\n` + ) + res.end() + } + } catch (error) { + logger.error('❌ OpenAI API Key test failed:', error) + + if (!res.headersSent) { + return res.status(500).json({ + error: 'Test failed', + message: error.message || 'Internal server error' + }) + } + + res.write( + `data: ${JSON.stringify({ type: 'error', error: error.message || 'Test failed' })}\n\n` + ) + res.end() + } +}) + // 📊 用户模型统计查询接口 - 安全的自查询接口 router.post('/api/user-model-stats', async (req, res) => { try { @@ -946,20 +1292,25 @@ router.post('/api/user-model-stats', async (req, res) => { const today = redis.getDateStringInTimezone() const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}` - const pattern = - period === 'daily' - ? `usage:${keyId}:model:daily:*:${today}` - : `usage:${keyId}:model:monthly:*:${currentMonth}` + let pattern + let matchRegex + if (period === 'daily') { + pattern = `usage:${keyId}:model:daily:*:${today}` + matchRegex = /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/ + } else if (period === 'alltime') { + pattern = `usage:${keyId}:model:alltime:*` + matchRegex = /usage:.+:model:alltime:(.+)$/ + } else { + // monthly + pattern = `usage:${keyId}:model:monthly:*:${currentMonth}` + matchRegex = /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/ + } const results = await redis.scanAndGetAllChunked(pattern) const modelStats = [] for (const { key, data } of results) { - const match = key.match( - period === 'daily' - ? /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/ - : /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/ - ) + const match = key.match(matchRegex) if (!match) { continue @@ -977,6 +1328,15 @@ router.post('/api/user-model-stats', async (req, res) => { const costData = CostCalculator.calculateCost(usage, model) + // alltime 键不存储 allTokens,需要计算 + const allTokens = + period === 'alltime' + ? usage.input_tokens + + usage.output_tokens + + usage.cache_creation_input_tokens + + usage.cache_read_input_tokens + : parseInt(data.allTokens) || 0 + modelStats.push({ model, requests: parseInt(data.requests) || 0, @@ -984,7 +1344,7 @@ router.post('/api/user-model-stats', async (req, res) => { outputTokens: usage.output_tokens, cacheCreateTokens: usage.cache_creation_input_tokens, cacheReadTokens: usage.cache_read_input_tokens, - allTokens: parseInt(data.allTokens) || 0, + allTokens, costs: costData.costs, formatted: costData.formatted, pricing: costData.pricing @@ -1015,4 +1375,21 @@ router.post('/api/user-model-stats', async (req, res) => { } }) +// 📊 获取服务倍率配置(公开接口) +router.get('/service-rates', async (req, res) => { + try { + const rates = await serviceRatesService.getRates() + res.json({ + success: true, + data: rates + }) + } catch (error) { + logger.error('❌ Failed to get service rates:', error) + res.status(500).json({ + error: 'Internal server error', + message: 'Failed to retrieve service rates' + }) + } +}) + module.exports = router diff --git a/src/routes/openaiRoutes.js b/src/routes/openaiRoutes.js index 5ba62289..c999af97 100644 --- a/src/routes/openaiRoutes.js +++ b/src/routes/openaiRoutes.js @@ -9,6 +9,7 @@ const openaiAccountService = require('../services/openaiAccountService') const openaiResponsesAccountService = require('../services/openaiResponsesAccountService') const openaiResponsesRelayService = require('../services/openaiResponsesRelayService') const apiKeyService = require('../services/apiKeyService') +const redis = require('../models/redis') const crypto = require('crypto') const ProxyHelper = require('../utils/proxyHelper') const { updateRateLimitCounters } = require('../utils/rateLimitHelper') @@ -857,16 +858,18 @@ router.post('/v1/responses/compact', authenticateApiKey, handleResponses) // 使用情况统计端点 router.get('/usage', authenticateApiKey, async (req, res) => { try { - const { usage } = req.apiKey + const keyData = req.apiKey + // 按需查询 usage 数据 + const usage = await redis.getUsageStats(keyData.id) res.json({ object: 'usage', - total_tokens: usage.total.tokens, - total_requests: usage.total.requests, - daily_tokens: usage.daily.tokens, - daily_requests: usage.daily.requests, - monthly_tokens: usage.monthly.tokens, - monthly_requests: usage.monthly.requests + total_tokens: usage?.total?.tokens || 0, + total_requests: usage?.total?.requests || 0, + daily_tokens: usage?.daily?.tokens || 0, + daily_requests: usage?.daily?.requests || 0, + monthly_tokens: usage?.monthly?.tokens || 0, + monthly_requests: usage?.monthly?.requests || 0 }) } catch (error) { logger.error('Failed to get usage stats:', error) @@ -883,25 +886,26 @@ router.get('/usage', authenticateApiKey, async (req, res) => { router.get('/key-info', authenticateApiKey, async (req, res) => { try { const keyData = req.apiKey + // 按需查询 usage 数据(仅 key-info 端点需要) + const usage = await redis.getUsageStats(keyData.id) + const tokensUsed = usage?.total?.tokens || 0 res.json({ id: keyData.id, name: keyData.name, description: keyData.description, permissions: keyData.permissions || 'all', token_limit: keyData.tokenLimit, - tokens_used: keyData.usage.total.tokens, + tokens_used: tokensUsed, tokens_remaining: - keyData.tokenLimit > 0 - ? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens) - : null, + keyData.tokenLimit > 0 ? Math.max(0, keyData.tokenLimit - tokensUsed) : null, rate_limit: { window: keyData.rateLimitWindow, requests: keyData.rateLimitRequests }, usage: { - total: keyData.usage.total, - daily: keyData.usage.daily, - monthly: keyData.usage.monthly + total: usage?.total || {}, + daily: usage?.daily || {}, + monthly: usage?.monthly || {} } }) } catch (error) { diff --git a/src/routes/userRoutes.js b/src/routes/userRoutes.js index 131880ef..55cbcc4f 100644 --- a/src/routes/userRoutes.js +++ b/src/routes/userRoutes.js @@ -761,4 +761,166 @@ router.get('/admin/ldap-test', authenticateUserOrAdmin, requireAdmin, async (req } }) +// ═══════════════════════════════════════════════════════════════════════════ +// 额度卡核销相关路由 +// ═══════════════════════════════════════════════════════════════════════════ + +const quotaCardService = require('../services/quotaCardService') + +// 🎫 核销额度卡 +router.post('/redeem-card', authenticateUser, async (req, res) => { + try { + const { code, apiKeyId } = req.body + + if (!code) { + return res.status(400).json({ + error: 'Missing card code', + message: 'Card code is required' + }) + } + + if (!apiKeyId) { + return res.status(400).json({ + error: 'Missing API key ID', + message: 'API key ID is required' + }) + } + + // 验证 API Key 属于当前用户 + const keyData = await redis.getApiKey(apiKeyId) + if (!keyData || Object.keys(keyData).length === 0) { + return res.status(404).json({ + error: 'API key not found', + message: 'The specified API key does not exist' + }) + } + + if (keyData.userId !== req.user.id) { + return res.status(403).json({ + error: 'Forbidden', + message: 'You can only redeem cards to your own API keys' + }) + } + + // 执行核销 + const result = await quotaCardService.redeemCard(code, apiKeyId, req.user.id, req.user.username) + + logger.success(`🎫 User ${req.user.username} redeemed card ${code} to key ${apiKeyId}`) + + res.json({ + success: true, + data: result + }) + } catch (error) { + logger.error('❌ Redeem card error:', error) + res.status(400).json({ + error: 'Redeem failed', + message: error.message + }) + } +}) + +// 📋 获取用户的核销历史 +router.get('/redemption-history', authenticateUser, async (req, res) => { + try { + const { limit = 50, offset = 0 } = req.query + + const result = await quotaCardService.getRedemptions({ + userId: req.user.id, + limit: parseInt(limit), + offset: parseInt(offset) + }) + + res.json({ + success: true, + data: result + }) + } catch (error) { + logger.error('❌ Get redemption history error:', error) + res.status(500).json({ + error: 'Failed to get redemption history', + message: error.message + }) + } +}) + +// 📊 获取用户的额度信息 +router.get('/quota-info', authenticateUser, async (req, res) => { + try { + const { apiKeyId } = req.query + + if (!apiKeyId) { + return res.status(400).json({ + error: 'Missing API key ID', + message: 'API key ID is required' + }) + } + + // 验证 API Key 属于当前用户 + const keyData = await redis.getApiKey(apiKeyId) + if (!keyData || Object.keys(keyData).length === 0) { + return res.status(404).json({ + error: 'API key not found', + message: 'The specified API key does not exist' + }) + } + + if (keyData.userId !== req.user.id) { + return res.status(403).json({ + error: 'Forbidden', + message: 'You can only view your own API key quota' + }) + } + + // 检查是否为聚合 Key + if (keyData.isAggregated !== 'true') { + return res.json({ + success: true, + data: { + isAggregated: false, + message: 'This is a traditional API key, not using quota system' + } + }) + } + + // 解析聚合 Key 数据 + let permissions = [] + let serviceQuotaLimits = {} + let serviceQuotaUsed = {} + + try { + permissions = JSON.parse(keyData.permissions || '[]') + } catch (e) { + permissions = [keyData.permissions] + } + + try { + serviceQuotaLimits = JSON.parse(keyData.serviceQuotaLimits || '{}') + serviceQuotaUsed = JSON.parse(keyData.serviceQuotaUsed || '{}') + } catch (e) { + // 解析失败使用默认值 + } + + res.json({ + success: true, + data: { + isAggregated: true, + quotaLimit: parseFloat(keyData.quotaLimit || 0), + quotaUsed: parseFloat(keyData.quotaUsed || 0), + quotaRemaining: parseFloat(keyData.quotaLimit || 0) - parseFloat(keyData.quotaUsed || 0), + permissions, + serviceQuotaLimits, + serviceQuotaUsed, + expiresAt: keyData.expiresAt + } + }) + } catch (error) { + logger.error('❌ Get quota info error:', error) + res.status(500).json({ + error: 'Failed to get quota info', + message: error.message + }) + } +}) + module.exports = router diff --git a/src/services/accountGroupService.js b/src/services/accountGroupService.js index 3a79413b..c771b002 100644 --- a/src/services/accountGroupService.js +++ b/src/services/accountGroupService.js @@ -567,6 +567,34 @@ class AccountGroupService { } } + // 对于反向索引为空的账户,单独查询并补建索引(处理部分缺失情况) + const emptyIndexAccountIds = [] + for (const accountId of accountIds) { + const ids = accountGroupIdsMap.get(accountId) || [] + if (ids.length === 0) { + emptyIndexAccountIds.push(accountId) + } + } + if (emptyIndexAccountIds.length > 0 && emptyIndexAccountIds.length < accountIds.length) { + // 部分账户索引缺失,逐个查询并补建 + for (const accountId of emptyIndexAccountIds) { + try { + const groups = await this.getAccountGroups(accountId) + if (groups.length > 0) { + const groupIds = groups.map((g) => g.id) + accountGroupIdsMap.set(accountId, groupIds) + groupIds.forEach((id) => uniqueGroupIds.add(id)) + // 异步补建反向索引 + client + .sadd(`${this.REVERSE_INDEX_PREFIX}${platform}:${accountId}`, ...groupIds) + .catch(() => {}) + } + } catch { + // 忽略错误,保持空数组 + } + } + } + // 批量获取分组详情 const groupDetailsMap = new Map() if (uniqueGroupIds.size > 0) { diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index 982c9740..ed07ec01 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -3,6 +3,7 @@ const { v4: uuidv4 } = require('uuid') const config = require('../../config/config') const redis = require('../models/redis') const logger = require('../utils/logger') +const serviceRatesService = require('./serviceRatesService') const ACCOUNT_TYPE_CONFIG = { claude: { prefix: 'claude:account:' }, @@ -89,7 +90,7 @@ class ApiKeyService { azureOpenaiAccountId = null, bedrockAccountId = null, // 添加 Bedrock 账号ID支持 droidAccountId = null, - permissions = 'all', // 可选值:'claude'、'gemini'、'openai'、'droid' 或 'all' + permissions = 'all', // 可选值:'claude'、'gemini'、'openai'、'droid' 或 'all',聚合Key为数组 isActive = true, concurrencyLimit = 0, rateLimitWindow = null, @@ -106,7 +107,13 @@ class ApiKeyService { activationDays = 0, // 新增:激活后有效天数(0表示不使用此功能) activationUnit = 'days', // 新增:激活时间单位 'hours' 或 'days' expirationMode = 'fixed', // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活) - icon = '' // 新增:图标(base64编码) + icon = '', // 新增:图标(base64编码) + // 聚合 Key 相关字段 + isAggregated = false, // 是否为聚合 Key + quotaLimit = 0, // CC 额度上限(聚合 Key 使用) + quotaUsed = 0, // 已消耗 CC 额度 + serviceQuotaLimits = {}, // 分服务额度限制 { claude: 50, codex: 30 } + serviceQuotaUsed = {} // 分服务已消耗额度 } = options // 生成简单的API Key (64字符十六进制) @@ -114,6 +121,16 @@ class ApiKeyService { const keyId = uuidv4() const hashedKey = this._hashApiKey(apiKey) + // 处理 permissions:聚合 Key 使用数组,传统 Key 使用字符串 + let permissionsValue = permissions + if (isAggregated && !Array.isArray(permissions)) { + // 聚合 Key 但 permissions 不是数组,转换为数组 + permissionsValue = + permissions === 'all' + ? ['claude', 'codex', 'gemini', 'droid', 'bedrock', 'azure', 'ccr'] + : [permissions] + } + const keyData = { id: keyId, name, @@ -132,7 +149,9 @@ class ApiKeyService { azureOpenaiAccountId: azureOpenaiAccountId || '', bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID droidAccountId: droidAccountId || '', - permissions: permissions || 'all', + permissions: Array.isArray(permissionsValue) + ? JSON.stringify(permissionsValue) + : permissionsValue || 'all', enableModelRestriction: String(enableModelRestriction), restrictedModels: JSON.stringify(restrictedModels || []), enableClientRestriction: String(enableClientRestriction || false), @@ -152,7 +171,13 @@ class ApiKeyService { createdBy: options.createdBy || 'admin', userId: options.userId || '', userUsername: options.userUsername || '', - icon: icon || '' // 新增:图标(base64编码) + icon: icon || '', // 新增:图标(base64编码) + // 聚合 Key 相关字段 + isAggregated: String(isAggregated), + quotaLimit: String(quotaLimit || 0), + quotaUsed: String(quotaUsed || 0), + serviceQuotaLimits: JSON.stringify(serviceQuotaLimits || {}), + serviceQuotaUsed: JSON.stringify(serviceQuotaUsed || {}) } // 保存API Key数据并建立哈希映射 @@ -182,7 +207,17 @@ class ApiKeyService { logger.warn(`Failed to add key ${keyId} to API Key index:`, err.message) } - logger.success(`🔑 Generated new API key: ${name} (${keyId})`) + logger.success( + `🔑 Generated new API key: ${name} (${keyId})${isAggregated ? ' [Aggregated]' : ''}` + ) + + // 解析 permissions 用于返回 + let parsedPermissions = keyData.permissions + try { + parsedPermissions = JSON.parse(keyData.permissions) + } catch (e) { + // 不是 JSON,保持原值(传统 Key 的字符串格式) + } return { id: keyId, @@ -202,7 +237,7 @@ class ApiKeyService { azureOpenaiAccountId: keyData.azureOpenaiAccountId, bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID droidAccountId: keyData.droidAccountId, - permissions: keyData.permissions, + permissions: parsedPermissions, enableModelRestriction: keyData.enableModelRestriction === 'true', restrictedModels: JSON.parse(keyData.restrictedModels), enableClientRestriction: keyData.enableClientRestriction === 'true', @@ -218,7 +253,13 @@ class ApiKeyService { activatedAt: keyData.activatedAt, createdAt: keyData.createdAt, expiresAt: keyData.expiresAt, - createdBy: keyData.createdBy + createdBy: keyData.createdBy, + // 聚合 Key 相关字段 + isAggregated: keyData.isAggregated === 'true', + quotaLimit: parseFloat(keyData.quotaLimit || 0), + quotaUsed: parseFloat(keyData.quotaUsed || 0), + serviceQuotaLimits: JSON.parse(keyData.serviceQuotaLimits || '{}'), + serviceQuotaUsed: JSON.parse(keyData.serviceQuotaUsed || '{}') } } @@ -300,15 +341,26 @@ class ApiKeyService { } } - // 获取使用统计(供返回数据使用) - const usage = await redis.getUsageStats(keyData.id) + // 按需获取费用统计(仅在有限制时查询,减少 Redis 调用) + const dailyCostLimit = parseFloat(keyData.dailyCostLimit || 0) + const totalCostLimit = parseFloat(keyData.totalCostLimit || 0) + const weeklyOpusCostLimit = parseFloat(keyData.weeklyOpusCostLimit || 0) - // 获取费用统计 - const [dailyCost, costStats] = await Promise.all([ - redis.getDailyCost(keyData.id), - redis.getCostStats(keyData.id) - ]) - const totalCost = costStats?.total || 0 + const costQueries = [] + if (dailyCostLimit > 0) { + costQueries.push(redis.getDailyCost(keyData.id).then((v) => ({ dailyCost: v || 0 }))) + } + if (totalCostLimit > 0) { + costQueries.push(redis.getCostStats(keyData.id).then((v) => ({ totalCost: v?.total || 0 }))) + } + if (weeklyOpusCostLimit > 0) { + costQueries.push( + redis.getWeeklyOpusCost(keyData.id).then((v) => ({ weeklyOpusCost: v || 0 })) + ) + } + + const costData = + costQueries.length > 0 ? Object.assign({}, ...(await Promise.all(costQueries))) : {} // 更新最后使用时间(优化:只在实际API调用时更新,而不是验证时) // 注意:lastUsedAt的更新已移至recordUsage方法中 @@ -339,6 +391,26 @@ class ApiKeyService { tags = [] } + // 解析 permissions(聚合 Key 为数组,传统 Key 为字符串) + let permissions = keyData.permissions || 'all' + try { + permissions = JSON.parse(keyData.permissions) + } catch (e) { + // 不是 JSON,保持原值 + } + + // 解析聚合 Key 相关字段 + let serviceQuotaLimits = {} + let serviceQuotaUsed = {} + try { + serviceQuotaLimits = keyData.serviceQuotaLimits + ? JSON.parse(keyData.serviceQuotaLimits) + : {} + serviceQuotaUsed = keyData.serviceQuotaUsed ? JSON.parse(keyData.serviceQuotaUsed) : {} + } catch (e) { + // 解析失败使用默认值 + } + return { valid: true, keyData: { @@ -354,7 +426,7 @@ class ApiKeyService { azureOpenaiAccountId: keyData.azureOpenaiAccountId, bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID droidAccountId: keyData.droidAccountId, - permissions: keyData.permissions || 'all', + permissions, tokenLimit: parseInt(keyData.tokenLimit), concurrencyLimit: parseInt(keyData.concurrencyLimit || 0), rateLimitWindow: parseInt(keyData.rateLimitWindow || 0), @@ -364,14 +436,19 @@ class ApiKeyService { restrictedModels, enableClientRestriction: keyData.enableClientRestriction === 'true', allowedClients, - dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), - totalCostLimit: parseFloat(keyData.totalCostLimit || 0), - weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0), - dailyCost: dailyCost || 0, - totalCost, - weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0, + dailyCostLimit, + totalCostLimit, + weeklyOpusCostLimit, + dailyCost: costData.dailyCost || 0, + totalCost: costData.totalCost || 0, + weeklyOpusCost: costData.weeklyOpusCost || 0, tags, - usage + // 聚合 Key 相关字段 + isAggregated: keyData.isAggregated === 'true', + quotaLimit: parseFloat(keyData.quotaLimit || 0), + quotaUsed: parseFloat(keyData.quotaUsed || 0), + serviceQuotaLimits, + serviceQuotaUsed } } } catch (error) { @@ -982,19 +1059,37 @@ class ApiKeyService { 'tags', 'userId', // 新增:用户ID(所有者变更) 'userUsername', // 新增:用户名(所有者变更) - 'createdBy' // 新增:创建者(所有者变更) + 'createdBy', // 新增:创建者(所有者变更) + // 聚合 Key 相关字段 + 'isAggregated', + 'quotaLimit', + 'quotaUsed', + 'serviceQuotaLimits', + 'serviceQuotaUsed' ] const updatedData = { ...keyData } for (const [field, value] of Object.entries(updates)) { if (allowedUpdates.includes(field)) { - if (field === 'restrictedModels' || field === 'allowedClients' || field === 'tags') { - // 特殊处理数组字段 - updatedData[field] = JSON.stringify(value || []) + if ( + field === 'restrictedModels' || + field === 'allowedClients' || + field === 'tags' || + field === 'serviceQuotaLimits' || + field === 'serviceQuotaUsed' + ) { + // 特殊处理数组/对象字段 + updatedData[field] = JSON.stringify( + value || (field === 'serviceQuotaLimits' || field === 'serviceQuotaUsed' ? {} : []) + ) + } else if (field === 'permissions') { + // permissions 可能是数组(聚合 Key)或字符串(传统 Key) + updatedData[field] = Array.isArray(value) ? JSON.stringify(value) : value || 'all' } else if ( field === 'enableModelRestriction' || field === 'enableClientRestriction' || - field === 'isActivated' + field === 'isActivated' || + field === 'isAggregated' ) { // 布尔值转字符串 updatedData[field] = String(value) @@ -2171,6 +2266,375 @@ class ApiKeyService { return 0 } } + + // ═══════════════════════════════════════════════════════════════════════════ + // 聚合 Key 相关方法 + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * 判断是否为聚合 Key + */ + isAggregatedKey(keyData) { + return keyData && keyData.isAggregated === 'true' + } + + /** + * 获取转换为聚合 Key 的预览信息 + * @param {string} keyId - API Key ID + * @returns {Object} 转换预览信息 + */ + async getConvertToAggregatedPreview(keyId) { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { + throw new Error('API key not found') + } + + if (keyData.isAggregated === 'true') { + throw new Error('API key is already aggregated') + } + + // 获取当前服务倍率 + const ratesConfig = await serviceRatesService.getRates() + + // 确定原服务类型 + let originalService = keyData.permissions || 'all' + try { + const parsed = JSON.parse(originalService) + if (Array.isArray(parsed)) { + originalService = parsed[0] || 'claude' + } + } catch (e) { + // 不是 JSON,保持原值 + } + + // 映射 permissions 到服务类型 + const serviceMap = { + all: 'claude', + claude: 'claude', + gemini: 'gemini', + openai: 'codex', + droid: 'droid', + bedrock: 'bedrock', + azure: 'azure', + ccr: 'ccr' + } + const mappedService = serviceMap[originalService] || 'claude' + const serviceRate = ratesConfig.rates[mappedService] || 1.0 + + // 获取已消耗的真实成本 + const costStats = await redis.getCostStats(keyId) + const totalRealCost = costStats?.total || 0 + + // 获取原限额 + const originalLimit = parseFloat(keyData.totalCostLimit || 0) + + // 计算建议的 CC 额度 + const suggestedQuotaLimit = originalLimit * serviceRate + const suggestedQuotaUsed = totalRealCost * serviceRate + + // 获取可用服务列表 + const availableServices = Object.keys(ratesConfig.rates) + + return { + keyId, + keyName: keyData.name, + originalService: mappedService, + originalPermissions: originalService, + originalLimit, + originalUsed: totalRealCost, + serviceRate, + suggestedQuotaLimit: Math.round(suggestedQuotaLimit * 1000) / 1000, + suggestedQuotaUsed: Math.round(suggestedQuotaUsed * 1000) / 1000, + availableServices, + ratesConfig: ratesConfig.rates + } + } catch (error) { + logger.error('❌ Failed to get convert preview:', error) + throw error + } + } + + /** + * 将传统 Key 转换为聚合 Key + * @param {string} keyId - API Key ID + * @param {Object} options - 转换选项 + * @param {number} options.quotaLimit - CC 额度上限 + * @param {Array} options.permissions - 允许的服务列表 + * @param {Object} options.serviceQuotaLimits - 分服务额度限制(可选) + * @returns {Object} 转换结果 + */ + async convertToAggregated(keyId, options = {}) { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { + throw new Error('API key not found') + } + + if (keyData.isAggregated === 'true') { + throw new Error('API key is already aggregated') + } + + const { + quotaLimit, + permissions, + serviceQuotaLimits = {}, + quotaUsed = null // 如果不传,自动计算 + } = options + + if (quotaLimit === undefined || quotaLimit === null) { + throw new Error('quotaLimit is required') + } + + if (!permissions || !Array.isArray(permissions) || permissions.length === 0) { + throw new Error('permissions must be a non-empty array') + } + + // 计算已消耗的 CC 额度 + let calculatedQuotaUsed = quotaUsed + if (calculatedQuotaUsed === null) { + // 自动计算:获取原服务的倍率,按倍率换算已消耗成本 + const preview = await this.getConvertToAggregatedPreview(keyId) + calculatedQuotaUsed = preview.suggestedQuotaUsed + } + + // 更新 Key 数据 + const updates = { + isAggregated: true, + quotaLimit, + quotaUsed: calculatedQuotaUsed, + permissions, + serviceQuotaLimits, + serviceQuotaUsed: {} // 转换时重置分服务统计 + } + + await this.updateApiKey(keyId, updates) + + logger.success(`🔄 Converted API key ${keyId} to aggregated key with ${quotaLimit} CC quota`) + + return { + success: true, + keyId, + quotaLimit, + quotaUsed: calculatedQuotaUsed, + permissions + } + } catch (error) { + logger.error('❌ Failed to convert to aggregated key:', error) + throw error + } + } + + /** + * 快速检查聚合 Key 额度(用于请求前检查) + * @param {string} keyId - API Key ID + * @returns {Object} { allowed: boolean, reason?: string, quotaRemaining?: number } + */ + async quickQuotaCheck(keyId) { + try { + const [quotaUsed, quotaLimit, isAggregated, isActive] = await redis.client.hmget( + `api_key:${keyId}`, + 'quotaUsed', + 'quotaLimit', + 'isAggregated', + 'isActive' + ) + + // 非聚合 Key 不检查额度 + if (isAggregated !== 'true') { + return { allowed: true, isAggregated: false } + } + + if (isActive !== 'true') { + return { allowed: false, reason: 'inactive', isAggregated: true } + } + + const used = parseFloat(quotaUsed || 0) + const limit = parseFloat(quotaLimit || 0) + + // 额度为 0 表示不限制 + if (limit === 0) { + return { allowed: true, isAggregated: true, quotaRemaining: Infinity } + } + + if (used >= limit) { + return { + allowed: false, + reason: 'quota_exceeded', + isAggregated: true, + quotaUsed: used, + quotaLimit: limit + } + } + + return { allowed: true, isAggregated: true, quotaRemaining: limit - used } + } catch (error) { + logger.error('❌ Quick quota check failed:', error) + // 出错时允许通过,避免阻塞请求 + return { allowed: true, error: error.message } + } + } + + /** + * 异步扣减聚合 Key 额度(请求完成后调用) + * @param {string} keyId - API Key ID + * @param {number} costUSD - 真实成本(USD) + * @param {string} service - 服务类型 + * @returns {Promise} + */ + async deductQuotaAsync(keyId, costUSD, service) { + // 使用 setImmediate 确保不阻塞响应 + setImmediate(async () => { + try { + // 获取服务倍率 + const rate = await serviceRatesService.getServiceRate(service) + const quotaToDeduct = costUSD * rate + + // 原子更新总额度 + await redis.client.hincrbyfloat(`api_key:${keyId}`, 'quotaUsed', quotaToDeduct) + + // 更新分服务额度 + const serviceQuotaUsedStr = await redis.client.hget(`api_key:${keyId}`, 'serviceQuotaUsed') + let serviceQuotaUsed = {} + try { + serviceQuotaUsed = JSON.parse(serviceQuotaUsedStr || '{}') + } catch (e) { + serviceQuotaUsed = {} + } + serviceQuotaUsed[service] = (serviceQuotaUsed[service] || 0) + quotaToDeduct + await redis.client.hset( + `api_key:${keyId}`, + 'serviceQuotaUsed', + JSON.stringify(serviceQuotaUsed) + ) + + logger.debug( + `📊 Deducted ${quotaToDeduct.toFixed(4)} CC quota from key ${keyId} (service: ${service}, rate: ${rate})` + ) + } catch (error) { + logger.error(`❌ Failed to deduct quota for key ${keyId}:`, error) + } + }) + } + + /** + * 增加聚合 Key 额度(用于核销额度卡) + * @param {string} keyId - API Key ID + * @param {number} quotaAmount - 要增加的 CC 额度 + * @returns {Promise} { success: boolean, newQuotaLimit: number } + */ + async addQuota(keyId, quotaAmount) { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { + throw new Error('API key not found') + } + + if (keyData.isAggregated !== 'true') { + throw new Error('Only aggregated keys can add quota') + } + + const currentLimit = parseFloat(keyData.quotaLimit || 0) + const newLimit = currentLimit + quotaAmount + + await redis.client.hset(`api_key:${keyId}`, 'quotaLimit', String(newLimit)) + + logger.success(`💰 Added ${quotaAmount} CC quota to key ${keyId}, new limit: ${newLimit}`) + + return { success: true, previousLimit: currentLimit, newQuotaLimit: newLimit } + } catch (error) { + logger.error('❌ Failed to add quota:', error) + throw error + } + } + + /** + * 减少聚合 Key 额度(用于撤销核销) + * @param {string} keyId - API Key ID + * @param {number} quotaAmount - 要减少的 CC 额度 + * @returns {Promise} { success: boolean, newQuotaLimit: number, actualDeducted: number } + */ + async deductQuotaLimit(keyId, quotaAmount) { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { + throw new Error('API key not found') + } + + if (keyData.isAggregated !== 'true') { + throw new Error('Only aggregated keys can deduct quota') + } + + const currentLimit = parseFloat(keyData.quotaLimit || 0) + const currentUsed = parseFloat(keyData.quotaUsed || 0) + + // 不能扣到比已使用的还少 + const minLimit = currentUsed + const actualDeducted = Math.min(quotaAmount, currentLimit - minLimit) + const newLimit = Math.max(currentLimit - quotaAmount, minLimit) + + await redis.client.hset(`api_key:${keyId}`, 'quotaLimit', String(newLimit)) + + logger.success( + `💸 Deducted ${actualDeducted} CC quota from key ${keyId}, new limit: ${newLimit}` + ) + + return { success: true, previousLimit: currentLimit, newQuotaLimit: newLimit, actualDeducted } + } catch (error) { + logger.error('❌ Failed to deduct quota limit:', error) + throw error + } + } + + /** + * 延长 API Key 有效期(用于核销时间卡) + * @param {string} keyId - API Key ID + * @param {number} amount - 时间数量 + * @param {string} unit - 时间单位 'hours' | 'days' | 'months' + * @returns {Promise} { success: boolean, newExpiresAt: string } + */ + async extendExpiry(keyId, amount, unit = 'days') { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { + throw new Error('API key not found') + } + + // 计算新的过期时间 + let baseDate = keyData.expiresAt ? new Date(keyData.expiresAt) : new Date() + // 如果已过期,从当前时间开始计算 + if (baseDate < new Date()) { + baseDate = new Date() + } + + let milliseconds + switch (unit) { + case 'hours': + milliseconds = amount * 60 * 60 * 1000 + break + case 'months': + // 简化处理:1个月 = 30天 + milliseconds = amount * 30 * 24 * 60 * 60 * 1000 + break + case 'days': + default: + milliseconds = amount * 24 * 60 * 60 * 1000 + } + + const newExpiresAt = new Date(baseDate.getTime() + milliseconds).toISOString() + + await this.updateApiKey(keyId, { expiresAt: newExpiresAt }) + + logger.success( + `⏰ Extended key ${keyId} expiry by ${amount} ${unit}, new expiry: ${newExpiresAt}` + ) + + return { success: true, previousExpiresAt: keyData.expiresAt, newExpiresAt } + } catch (error) { + logger.error('❌ Failed to extend expiry:', error) + throw error + } + } } // 导出实例和单独的方法 diff --git a/src/services/quotaCardService.js b/src/services/quotaCardService.js new file mode 100644 index 00000000..009ef397 --- /dev/null +++ b/src/services/quotaCardService.js @@ -0,0 +1,579 @@ +/** + * 额度卡/时间卡服务 + * 管理员生成卡,用户核销,管理员可撤销 + */ +const redis = require('../models/redis') +const logger = require('../utils/logger') +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') + +class QuotaCardService { + constructor() { + this.CARD_PREFIX = 'quota_card:' + this.REDEMPTION_PREFIX = 'redemption:' + this.CARD_CODE_PREFIX = 'CC' // 卡号前缀 + } + + /** + * 生成卡号(16位,格式:CC_XXXX_XXXX_XXXX) + */ + _generateCardCode() { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // 排除容易混淆的字符 + let code = '' + for (let i = 0; i < 12; i++) { + code += chars.charAt(crypto.randomInt(chars.length)) + } + return `${this.CARD_CODE_PREFIX}_${code.slice(0, 4)}_${code.slice(4, 8)}_${code.slice(8, 12)}` + } + + /** + * 创建额度卡/时间卡 + * @param {Object} options - 卡配置 + * @param {string} options.type - 卡类型:'quota' | 'time' | 'combo' + * @param {number} options.quotaAmount - CC 额度数量(quota/combo 类型必填) + * @param {number} options.timeAmount - 时间数量(time/combo 类型必填) + * @param {string} options.timeUnit - 时间单位:'hours' | 'days' | 'months' + * @param {string} options.expiresAt - 卡本身的有效期(可选) + * @param {string} options.note - 备注 + * @param {string} options.createdBy - 创建者 ID + * @returns {Object} 创建的卡信息 + */ + async createCard(options = {}) { + try { + const { + type = 'quota', + quotaAmount = 0, + timeAmount = 0, + timeUnit = 'days', + expiresAt = null, + note = '', + createdBy = 'admin' + } = options + + // 验证 + if (!['quota', 'time', 'combo'].includes(type)) { + throw new Error('Invalid card type') + } + + if ((type === 'quota' || type === 'combo') && (!quotaAmount || quotaAmount <= 0)) { + throw new Error('quotaAmount is required for quota/combo cards') + } + + if ((type === 'time' || type === 'combo') && (!timeAmount || timeAmount <= 0)) { + throw new Error('timeAmount is required for time/combo cards') + } + + const cardId = uuidv4() + const cardCode = this._generateCardCode() + + const cardData = { + id: cardId, + code: cardCode, + type, + quotaAmount: String(quotaAmount || 0), + timeAmount: String(timeAmount || 0), + timeUnit: timeUnit || 'days', + status: 'unused', // unused | redeemed | revoked | expired + createdBy, + createdAt: new Date().toISOString(), + expiresAt: expiresAt || '', + note: note || '', + // 核销信息 + redeemedBy: '', + redeemedByUsername: '', + redeemedApiKeyId: '', + redeemedApiKeyName: '', + redeemedAt: '', + // 撤销信息 + revokedAt: '', + revokedBy: '', + revokeReason: '' + } + + // 保存卡数据 + await redis.client.hset(`${this.CARD_PREFIX}${cardId}`, cardData) + + // 建立卡号到 ID 的映射(用于快速查找) + await redis.client.set(`quota_card_code:${cardCode}`, cardId) + + // 添加到卡列表索引 + await redis.client.sadd('quota_cards:all', cardId) + await redis.client.sadd(`quota_cards:status:${cardData.status}`, cardId) + + logger.success(`🎫 Created ${type} card: ${cardCode} (${cardId})`) + + return { + id: cardId, + code: cardCode, + type, + quotaAmount: parseFloat(quotaAmount || 0), + timeAmount: parseInt(timeAmount || 0), + timeUnit, + status: 'unused', + createdBy, + createdAt: cardData.createdAt, + expiresAt: cardData.expiresAt, + note + } + } catch (error) { + logger.error('❌ Failed to create card:', error) + throw error + } + } + + /** + * 批量创建卡 + * @param {Object} options - 卡配置 + * @param {number} count - 创建数量 + * @returns {Array} 创建的卡列表 + */ + async createCardsBatch(options = {}, count = 1) { + const cards = [] + for (let i = 0; i < count; i++) { + const card = await this.createCard(options) + cards.push(card) + } + logger.success(`🎫 Batch created ${count} cards`) + return cards + } + + /** + * 通过卡号获取卡信息 + */ + async getCardByCode(code) { + try { + const cardId = await redis.client.get(`quota_card_code:${code}`) + if (!cardId) { + return null + } + return await this.getCardById(cardId) + } catch (error) { + logger.error('❌ Failed to get card by code:', error) + return null + } + } + + /** + * 通过 ID 获取卡信息 + */ + async getCardById(cardId) { + try { + const cardData = await redis.client.hgetall(`${this.CARD_PREFIX}${cardId}`) + if (!cardData || Object.keys(cardData).length === 0) { + return null + } + + return { + id: cardData.id, + code: cardData.code, + type: cardData.type, + quotaAmount: parseFloat(cardData.quotaAmount || 0), + timeAmount: parseInt(cardData.timeAmount || 0), + timeUnit: cardData.timeUnit, + status: cardData.status, + createdBy: cardData.createdBy, + createdAt: cardData.createdAt, + expiresAt: cardData.expiresAt, + note: cardData.note, + redeemedBy: cardData.redeemedBy, + redeemedByUsername: cardData.redeemedByUsername, + redeemedApiKeyId: cardData.redeemedApiKeyId, + redeemedApiKeyName: cardData.redeemedApiKeyName, + redeemedAt: cardData.redeemedAt, + revokedAt: cardData.revokedAt, + revokedBy: cardData.revokedBy, + revokeReason: cardData.revokeReason + } + } catch (error) { + logger.error('❌ Failed to get card:', error) + return null + } + } + + /** + * 获取所有卡列表 + * @param {Object} options - 查询选项 + * @param {string} options.status - 按状态筛选 + * @param {number} options.limit - 限制数量 + * @param {number} options.offset - 偏移量 + */ + async getAllCards(options = {}) { + try { + const { status, limit = 100, offset = 0 } = options + + let cardIds + if (status) { + cardIds = await redis.client.smembers(`quota_cards:status:${status}`) + } else { + cardIds = await redis.client.smembers('quota_cards:all') + } + + // 排序(按创建时间倒序) + const cards = [] + for (const cardId of cardIds) { + const card = await this.getCardById(cardId) + if (card) { + cards.push(card) + } + } + + cards.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + + // 分页 + const total = cards.length + const paginatedCards = cards.slice(offset, offset + limit) + + return { + cards: paginatedCards, + total, + limit, + offset + } + } catch (error) { + logger.error('❌ Failed to get all cards:', error) + return { cards: [], total: 0, limit: 100, offset: 0 } + } + } + + /** + * 核销卡 + * @param {string} code - 卡号 + * @param {string} apiKeyId - 目标 API Key ID + * @param {string} userId - 核销用户 ID + * @param {string} username - 核销用户名 + * @returns {Object} 核销结果 + */ + async redeemCard(code, apiKeyId, userId, username = '') { + try { + // 获取卡信息 + const card = await this.getCardByCode(code) + if (!card) { + throw new Error('Card not found') + } + + // 检查卡状态 + if (card.status !== 'unused') { + throw new Error(`Card is ${card.status}, cannot redeem`) + } + + // 检查卡是否过期 + if (card.expiresAt && new Date(card.expiresAt) < new Date()) { + // 更新卡状态为过期 + await this._updateCardStatus(card.id, 'expired') + throw new Error('Card has expired') + } + + // 获取 API Key 信息 + const apiKeyService = require('./apiKeyService') + const keyData = await redis.getApiKey(apiKeyId) + if (!keyData || Object.keys(keyData).length === 0) { + throw new Error('API key not found') + } + + // 检查 API Key 是否为聚合类型(只有聚合 Key 才能核销额度卡) + if (card.type !== 'time' && keyData.isAggregated !== 'true') { + throw new Error('Only aggregated keys can redeem quota cards') + } + + // 执行核销 + const redemptionId = uuidv4() + const now = new Date().toISOString() + + // 记录核销前状态 + const beforeQuota = parseFloat(keyData.quotaLimit || 0) + const beforeExpiry = keyData.expiresAt || '' + + // 应用卡效果 + let afterQuota = beforeQuota + let afterExpiry = beforeExpiry + + if (card.type === 'quota' || card.type === 'combo') { + const result = await apiKeyService.addQuota(apiKeyId, card.quotaAmount) + afterQuota = result.newQuotaLimit + } + + if (card.type === 'time' || card.type === 'combo') { + const result = await apiKeyService.extendExpiry(apiKeyId, card.timeAmount, card.timeUnit) + afterExpiry = result.newExpiresAt + } + + // 更新卡状态 + await redis.client.hset(`${this.CARD_PREFIX}${card.id}`, { + status: 'redeemed', + redeemedBy: userId, + redeemedByUsername: username, + redeemedApiKeyId: apiKeyId, + redeemedApiKeyName: keyData.name || '', + redeemedAt: now + }) + + // 更新状态索引 + await redis.client.srem(`quota_cards:status:unused`, card.id) + await redis.client.sadd(`quota_cards:status:redeemed`, card.id) + + // 创建核销记录 + const redemptionData = { + id: redemptionId, + cardId: card.id, + cardCode: card.code, + cardType: card.type, + userId, + username, + apiKeyId, + apiKeyName: keyData.name || '', + quotaAdded: String(card.type === 'time' ? 0 : card.quotaAmount), + timeAdded: String(card.type === 'quota' ? 0 : card.timeAmount), + timeUnit: card.timeUnit, + beforeQuota: String(beforeQuota), + afterQuota: String(afterQuota), + beforeExpiry, + afterExpiry, + timestamp: now, + status: 'active' // active | revoked + } + + await redis.client.hset(`${this.REDEMPTION_PREFIX}${redemptionId}`, redemptionData) + + // 添加到核销记录索引 + await redis.client.sadd('redemptions:all', redemptionId) + await redis.client.sadd(`redemptions:user:${userId}`, redemptionId) + await redis.client.sadd(`redemptions:apikey:${apiKeyId}`, redemptionId) + + logger.success(`✅ Card ${card.code} redeemed by ${username || userId} to key ${apiKeyId}`) + + return { + success: true, + redemptionId, + cardCode: card.code, + cardType: card.type, + quotaAdded: card.type === 'time' ? 0 : card.quotaAmount, + timeAdded: card.type === 'quota' ? 0 : card.timeAmount, + timeUnit: card.timeUnit, + beforeQuota, + afterQuota, + beforeExpiry, + afterExpiry + } + } catch (error) { + logger.error('❌ Failed to redeem card:', error) + throw error + } + } + + /** + * 撤销核销 + * @param {string} redemptionId - 核销记录 ID + * @param {string} revokedBy - 撤销者 ID + * @param {string} reason - 撤销原因 + * @returns {Object} 撤销结果 + */ + async revokeRedemption(redemptionId, revokedBy, reason = '') { + try { + // 获取核销记录 + const redemptionData = await redis.client.hgetall(`${this.REDEMPTION_PREFIX}${redemptionId}`) + if (!redemptionData || Object.keys(redemptionData).length === 0) { + throw new Error('Redemption record not found') + } + + if (redemptionData.status !== 'active') { + throw new Error('Redemption is already revoked') + } + + const apiKeyService = require('./apiKeyService') + const now = new Date().toISOString() + + // 撤销效果 + let actualQuotaDeducted = 0 + if (parseFloat(redemptionData.quotaAdded) > 0) { + const result = await apiKeyService.deductQuotaLimit( + redemptionData.apiKeyId, + parseFloat(redemptionData.quotaAdded) + ) + actualQuotaDeducted = result.actualDeducted + } + + // 注意:时间卡撤销比较复杂,这里简化处理,不回退时间 + // 如果需要回退时间,可以在这里添加逻辑 + + // 更新核销记录状态 + await redis.client.hset(`${this.REDEMPTION_PREFIX}${redemptionId}`, { + status: 'revoked', + revokedAt: now, + revokedBy, + revokeReason: reason, + actualQuotaDeducted: String(actualQuotaDeducted) + }) + + // 更新卡状态 + const { cardId } = redemptionData + await redis.client.hset(`${this.CARD_PREFIX}${cardId}`, { + status: 'revoked', + revokedAt: now, + revokedBy, + revokeReason: reason + }) + + // 更新状态索引 + await redis.client.srem(`quota_cards:status:redeemed`, cardId) + await redis.client.sadd(`quota_cards:status:revoked`, cardId) + + logger.success(`🔄 Revoked redemption ${redemptionId} by ${revokedBy}`) + + return { + success: true, + redemptionId, + cardCode: redemptionData.cardCode, + actualQuotaDeducted, + reason + } + } catch (error) { + logger.error('❌ Failed to revoke redemption:', error) + throw error + } + } + + /** + * 获取核销记录 + * @param {Object} options - 查询选项 + * @param {string} options.userId - 按用户筛选 + * @param {string} options.apiKeyId - 按 API Key 筛选 + * @param {number} options.limit - 限制数量 + * @param {number} options.offset - 偏移量 + */ + async getRedemptions(options = {}) { + try { + const { userId, apiKeyId, limit = 100, offset = 0 } = options + + let redemptionIds + if (userId) { + redemptionIds = await redis.client.smembers(`redemptions:user:${userId}`) + } else if (apiKeyId) { + redemptionIds = await redis.client.smembers(`redemptions:apikey:${apiKeyId}`) + } else { + redemptionIds = await redis.client.smembers('redemptions:all') + } + + const redemptions = [] + for (const id of redemptionIds) { + const data = await redis.client.hgetall(`${this.REDEMPTION_PREFIX}${id}`) + if (data && Object.keys(data).length > 0) { + redemptions.push({ + id: data.id, + cardId: data.cardId, + cardCode: data.cardCode, + cardType: data.cardType, + userId: data.userId, + username: data.username, + apiKeyId: data.apiKeyId, + apiKeyName: data.apiKeyName, + quotaAdded: parseFloat(data.quotaAdded || 0), + timeAdded: parseInt(data.timeAdded || 0), + timeUnit: data.timeUnit, + beforeQuota: parseFloat(data.beforeQuota || 0), + afterQuota: parseFloat(data.afterQuota || 0), + beforeExpiry: data.beforeExpiry, + afterExpiry: data.afterExpiry, + timestamp: data.timestamp, + status: data.status, + revokedAt: data.revokedAt, + revokedBy: data.revokedBy, + revokeReason: data.revokeReason, + actualQuotaDeducted: parseFloat(data.actualQuotaDeducted || 0) + }) + } + } + + // 排序(按时间倒序) + redemptions.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)) + + // 分页 + const total = redemptions.length + const paginatedRedemptions = redemptions.slice(offset, offset + limit) + + return { + redemptions: paginatedRedemptions, + total, + limit, + offset + } + } catch (error) { + logger.error('❌ Failed to get redemptions:', error) + return { redemptions: [], total: 0, limit: 100, offset: 0 } + } + } + + /** + * 删除未使用的卡 + */ + async deleteCard(cardId) { + try { + const card = await this.getCardById(cardId) + if (!card) { + throw new Error('Card not found') + } + + if (card.status !== 'unused') { + throw new Error('Only unused cards can be deleted') + } + + // 删除卡数据 + await redis.client.del(`${this.CARD_PREFIX}${cardId}`) + await redis.client.del(`quota_card_code:${card.code}`) + + // 从索引中移除 + await redis.client.srem('quota_cards:all', cardId) + await redis.client.srem(`quota_cards:status:unused`, cardId) + + logger.success(`🗑️ Deleted card ${card.code}`) + + return { success: true, cardCode: card.code } + } catch (error) { + logger.error('❌ Failed to delete card:', error) + throw error + } + } + + /** + * 更新卡状态(内部方法) + */ + async _updateCardStatus(cardId, newStatus) { + const card = await this.getCardById(cardId) + if (!card) { + return + } + + const oldStatus = card.status + await redis.client.hset(`${this.CARD_PREFIX}${cardId}`, 'status', newStatus) + + // 更新状态索引 + await redis.client.srem(`quota_cards:status:${oldStatus}`, cardId) + await redis.client.sadd(`quota_cards:status:${newStatus}`, cardId) + } + + /** + * 获取卡统计信息 + */ + async getCardStats() { + try { + const [unused, redeemed, revoked, expired] = await Promise.all([ + redis.client.scard('quota_cards:status:unused'), + redis.client.scard('quota_cards:status:redeemed'), + redis.client.scard('quota_cards:status:revoked'), + redis.client.scard('quota_cards:status:expired') + ]) + + return { + total: unused + redeemed + revoked + expired, + unused, + redeemed, + revoked, + expired + } + } catch (error) { + logger.error('❌ Failed to get card stats:', error) + return { total: 0, unused: 0, redeemed: 0, revoked: 0, expired: 0 } + } + } +} + +module.exports = new QuotaCardService() diff --git a/src/services/serviceRatesService.js b/src/services/serviceRatesService.js new file mode 100644 index 00000000..2d6f76bb --- /dev/null +++ b/src/services/serviceRatesService.js @@ -0,0 +1,227 @@ +/** + * 服务倍率配置服务 + * 管理不同服务的消费倍率,以 Claude 为基准(倍率 1.0) + * 用于聚合 Key 的虚拟额度计算 + */ +const redis = require('../models/redis') +const logger = require('../utils/logger') + +class ServiceRatesService { + constructor() { + this.CONFIG_KEY = 'system:service_rates' + this.cachedRates = null + this.cacheExpiry = 0 + this.CACHE_TTL = 60 * 1000 // 1分钟缓存 + } + + /** + * 获取默认倍率配置 + */ + getDefaultRates() { + return { + baseService: 'claude', + rates: { + claude: 1.0, // 基准:1 USD = 1 CC额度 + codex: 1.0, + gemini: 1.0, + droid: 1.0, + bedrock: 1.0, + azure: 1.0, + ccr: 1.0 + }, + updatedAt: null, + updatedBy: null + } + } + + /** + * 获取倍率配置(带缓存) + */ + async getRates() { + try { + // 检查缓存 + if (this.cachedRates && Date.now() < this.cacheExpiry) { + return this.cachedRates + } + + const configStr = await redis.client.get(this.CONFIG_KEY) + if (!configStr) { + const defaultRates = this.getDefaultRates() + this.cachedRates = defaultRates + this.cacheExpiry = Date.now() + this.CACHE_TTL + return defaultRates + } + + const storedConfig = JSON.parse(configStr) + // 合并默认值,确保新增服务有默认倍率 + const defaultRates = this.getDefaultRates() + storedConfig.rates = { + ...defaultRates.rates, + ...storedConfig.rates + } + + this.cachedRates = storedConfig + this.cacheExpiry = Date.now() + this.CACHE_TTL + return storedConfig + } catch (error) { + logger.error('获取服务倍率配置失败:', error) + return this.getDefaultRates() + } + } + + /** + * 保存倍率配置 + */ + async saveRates(config, updatedBy = 'admin') { + try { + const defaultRates = this.getDefaultRates() + + // 验证配置 + this.validateRates(config) + + const newConfig = { + baseService: config.baseService || defaultRates.baseService, + rates: { + ...defaultRates.rates, + ...config.rates + }, + updatedAt: new Date().toISOString(), + updatedBy + } + + await redis.client.set(this.CONFIG_KEY, JSON.stringify(newConfig)) + + // 清除缓存 + this.cachedRates = null + this.cacheExpiry = 0 + + logger.info(`✅ 服务倍率配置已更新 by ${updatedBy}`) + return newConfig + } catch (error) { + logger.error('保存服务倍率配置失败:', error) + throw error + } + } + + /** + * 验证倍率配置 + */ + validateRates(config) { + if (!config || typeof config !== 'object') { + throw new Error('无效的配置格式') + } + + if (config.rates) { + for (const [service, rate] of Object.entries(config.rates)) { + if (typeof rate !== 'number' || rate <= 0) { + throw new Error(`服务 ${service} 的倍率必须是正数`) + } + } + } + } + + /** + * 获取单个服务的倍率 + */ + async getServiceRate(service) { + const config = await this.getRates() + return config.rates[service] || 1.0 + } + + /** + * 计算消费的 CC 额度 + * @param {number} costUSD - 真实成本(USD) + * @param {string} service - 服务类型 + * @returns {number} CC 额度消耗 + */ + async calculateQuotaConsumption(costUSD, service) { + const rate = await this.getServiceRate(service) + return costUSD * rate + } + + /** + * 根据模型名称获取服务类型 + */ + getServiceFromModel(model) { + if (!model) { + return 'claude' + } + + const modelLower = model.toLowerCase() + + // Claude 系列 + if ( + modelLower.includes('claude') || + modelLower.includes('anthropic') || + modelLower.includes('opus') || + modelLower.includes('sonnet') || + modelLower.includes('haiku') + ) { + return 'claude' + } + + // OpenAI / Codex 系列 + if ( + modelLower.includes('gpt') || + modelLower.includes('o1') || + modelLower.includes('o3') || + modelLower.includes('o4') || + modelLower.includes('codex') || + modelLower.includes('davinci') || + modelLower.includes('curie') || + modelLower.includes('babbage') || + modelLower.includes('ada') + ) { + return 'codex' + } + + // Gemini 系列 + if ( + modelLower.includes('gemini') || + modelLower.includes('palm') || + modelLower.includes('bard') + ) { + return 'gemini' + } + + // Droid 系列 + if (modelLower.includes('droid') || modelLower.includes('factory')) { + return 'droid' + } + + // Bedrock 系列(通常带有 aws 或特定前缀) + if ( + modelLower.includes('bedrock') || + modelLower.includes('amazon') || + modelLower.includes('titan') + ) { + return 'bedrock' + } + + // Azure 系列 + if (modelLower.includes('azure')) { + return 'azure' + } + + // 默认返回 claude + return 'claude' + } + + /** + * 获取所有支持的服务列表 + */ + async getAvailableServices() { + const config = await this.getRates() + return Object.keys(config.rates) + } + + /** + * 清除缓存(用于测试或强制刷新) + */ + clearCache() { + this.cachedRates = null + this.cacheExpiry = 0 + } +} + +module.exports = new ServiceRatesService() diff --git a/src/utils/commonHelper.js b/src/utils/commonHelper.js index 2341a19c..9eb870df 100644 --- a/src/utils/commonHelper.js +++ b/src/utils/commonHelper.js @@ -313,6 +313,51 @@ const getTimeRemaining = (expiresAt) => { return Math.max(0, Math.floor((new Date(expiresAt).getTime() - Date.now()) / 1000)) } +// ============================================ +// 版本处理 +// ============================================ + +const fs = require('fs') +const path = require('path') + +// 获取应用版本号 +const getAppVersion = () => { + if (process.env.APP_VERSION) { + return process.env.APP_VERSION + } + if (process.env.VERSION) { + return process.env.VERSION + } + try { + const versionFile = path.join(__dirname, '..', '..', 'VERSION') + if (fs.existsSync(versionFile)) { + return fs.readFileSync(versionFile, 'utf8').trim() + } + } catch {} + try { + return require('../../package.json').version + } catch {} + return '1.0.0' +} + +// 版本比较: a > b +const versionGt = (a, b) => { + const pa = a.split('.').map(Number) + const pb = b.split('.').map(Number) + for (let i = 0; i < 3; i++) { + if ((pa[i] || 0) > (pb[i] || 0)) { + return true + } + if ((pa[i] || 0) < (pb[i] || 0)) { + return false + } + } + return false +} + +// 版本比较: a >= b +const versionGte = (a, b) => a === b || versionGt(a, b) + module.exports = { // 加密 createEncryptor, @@ -351,5 +396,9 @@ module.exports = { getDateInTimezone, getDateStringInTimezone, isExpired, - getTimeRemaining + getTimeRemaining, + // 版本 + getAppVersion, + versionGt, + versionGte } diff --git a/src/utils/testPayloadHelper.js b/src/utils/testPayloadHelper.js index c5e2ed41..f3a49066 100644 --- a/src/utils/testPayloadHelper.js +++ b/src/utils/testPayloadHelper.js @@ -24,9 +24,12 @@ function generateSessionString() { * @param {string} model - 模型名称 * @param {object} options - 可选配置 * @param {boolean} options.stream - 是否流式(默认false) + * @param {string} options.prompt - 自定义提示词(默认 'hi') + * @param {number} options.maxTokens - 最大输出 token(默认 1000) * @returns {object} 测试请求体 */ function createClaudeTestPayload(model = 'claude-sonnet-4-5-20250929', options = {}) { + const { stream, prompt = 'hi', maxTokens = 1000 } = options const payload = { model, messages: [ @@ -35,7 +38,7 @@ function createClaudeTestPayload(model = 'claude-sonnet-4-5-20250929', options = content: [ { type: 'text', - text: 'hi', + text: prompt, cache_control: { type: 'ephemeral' } @@ -55,11 +58,11 @@ function createClaudeTestPayload(model = 'claude-sonnet-4-5-20250929', options = metadata: { user_id: generateSessionString() }, - max_tokens: 21333, + max_tokens: maxTokens, temperature: 1 } - if (options.stream) { + if (stream) { payload.stream = true } @@ -234,9 +237,58 @@ async function sendStreamTestRequest(options) { } } +/** + * 生成 Gemini 测试请求体 + * @param {string} model - 模型名称 + * @param {object} options - 可选配置 + * @param {string} options.prompt - 自定义提示词(默认 'hi') + * @param {number} options.maxTokens - 最大输出 token(默认 100) + * @returns {object} 测试请求体 + */ +function createGeminiTestPayload(model = 'gemini-2.5-pro', options = {}) { + const { prompt = 'hi', maxTokens = 100 } = options + return { + contents: [ + { + role: 'user', + parts: [{ text: prompt }] + } + ], + generationConfig: { + maxOutputTokens: maxTokens, + temperature: 1 + } + } +} + +/** + * 生成 OpenAI Responses 测试请求体 + * @param {string} model - 模型名称 + * @param {object} options - 可选配置 + * @param {string} options.prompt - 自定义提示词(默认 'hi') + * @param {number} options.maxTokens - 最大输出 token(默认 100) + * @returns {object} 测试请求体 + */ +function createOpenAITestPayload(model = 'gpt-5', options = {}) { + const { prompt = 'hi', maxTokens = 100 } = options + return { + model, + input: [ + { + role: 'user', + content: prompt + } + ], + max_output_tokens: maxTokens, + stream: true + } +} + module.exports = { randomHex, generateSessionString, createClaudeTestPayload, + createGeminiTestPayload, + createOpenAITestPayload, sendStreamTestRequest } diff --git a/web/admin-spa/src/App.vue b/web/admin-spa/src/App.vue index c68bfba7..feb86053 100644 --- a/web/admin-spa/src/App.vue +++ b/web/admin-spa/src/App.vue @@ -4,7 +4,6 @@ - @@ -13,12 +12,10 @@ import { onMounted, ref } from 'vue' import { useAuthStore } from '@/stores/auth' import { useThemeStore } from '@/stores/theme' import ToastNotification from '@/components/common/ToastNotification.vue' -import ConfirmDialog from '@/components/common/ConfirmDialog.vue' const authStore = useAuthStore() const themeStore = useThemeStore() const toastRef = ref() -const confirmRef = ref() onMounted(() => { // 初始化主题 diff --git a/web/admin-spa/src/assets/styles/components.css b/web/admin-spa/src/assets/styles/components.css index 9e00227d..86d5be5d 100644 --- a/web/admin-spa/src/assets/styles/components.css +++ b/web/admin-spa/src/assets/styles/components.css @@ -49,8 +49,8 @@ background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); color: white; box-shadow: - 0 10px 15px -3px rgba(102, 126, 234, 0.3), - 0 4px 6px -2px rgba(102, 126, 234, 0.05); + 0 10px 15px -3px rgba(var(--primary-rgb), 0.3), + 0 4px 6px -2px rgba(var(--primary-rgb), 0.05); transform: translateY(-1px); } @@ -87,18 +87,6 @@ transition: all 0.3s ease; } -.stat-card::before { - content: ''; - position: absolute; - top: -50%; - left: -50%; - width: 200%; - height: 200%; - background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%); - opacity: 0; - transition: opacity 0.3s ease; -} - .stat-card:hover { transform: translateY(-4px); box-shadow: @@ -106,10 +94,6 @@ 0 10px 10px -5px rgba(0, 0, 0, 0.04); } -.stat-card:hover::before { - opacity: 1; -} - .stat-icon { width: 56px; height: 56px; @@ -160,15 +144,15 @@ background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); color: white; box-shadow: - 0 10px 15px -3px rgba(102, 126, 234, 0.3), - 0 4px 6px -2px rgba(102, 126, 234, 0.05); + 0 10px 15px -3px rgba(var(--primary-rgb), 0.3), + 0 4px 6px -2px rgba(var(--primary-rgb), 0.05); } .btn-primary:hover { transform: translateY(-1px); box-shadow: - 0 20px 25px -5px rgba(102, 126, 234, 0.3), - 0 10px 10px -5px rgba(102, 126, 234, 0.1); + 0 20px 25px -5px rgba(var(--primary-rgb), 0.3), + 0 10px 10px -5px rgba(var(--primary-rgb), 0.1); } .btn-success { @@ -202,7 +186,7 @@ } .btn-secondary { - background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%); + background: linear-gradient(135deg, var(--text-secondary) 0%, var(--bg-gradient-end) 100%); color: white; box-shadow: 0 10px 15px -3px rgba(107, 114, 128, 0.3), @@ -231,7 +215,7 @@ outline: none; border-color: var(--primary-color); box-shadow: - 0 0 0 3px rgba(102, 126, 234, 0.1), + 0 0 0 3px rgba(var(--primary-rgb), 0.1), 0 10px 15px -3px rgba(0, 0, 0, 0.1); background: rgba(255, 255, 255, 0.95); } @@ -251,7 +235,7 @@ } .table-row:hover { - background: rgba(102, 126, 234, 0.05); + background: rgba(var(--primary-rgb), 0.05); transform: scale(1.005); } @@ -276,8 +260,8 @@ } .dark .modal-content { - background: rgba(17, 24, 39, 0.95); - border: 1px solid rgba(75, 85, 99, 0.3); + background: var(--glass-strong-color); + border: 1px solid var(--border-color); box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.05); @@ -419,8 +403,8 @@ /* 玻璃态容器 */ .glass, .glass-strong { - margin: 16px; - border-radius: 20px; + margin: 0; + border-radius: 16px; } /* 统计卡片 */ diff --git a/web/admin-spa/src/assets/styles/global.css b/web/admin-spa/src/assets/styles/global.css index f17f7183..376a422e 100644 --- a/web/admin-spa/src/assets/styles/global.css +++ b/web/admin-spa/src/assets/styles/global.css @@ -47,6 +47,70 @@ --table-hover: rgba(129, 140, 248, 0.1); } +/* 覆盖 Tailwind v3 的暗黑模式背景色使用主题色 */ +.dark .bg-gray-800, +.dark\:bg-gray-800:is(.dark *) { + background-color: var(--glass-strong-color) !important; +} + +.dark .bg-gray-700, +.dark\:bg-gray-700:is(.dark *) { + background-color: var(--bg-gradient-mid) !important; +} + +.dark .bg-gray-900, +.dark\:bg-gray-900:is(.dark *) { + background-color: var(--bg-gradient-start) !important; +} + +/* 覆盖带透明度的背景色 */ +.dark\:bg-gray-800\/40:is(.dark *) { + background-color: color-mix(in srgb, var(--glass-strong-color) 40%, transparent) !important; +} + +.dark\:bg-gray-700\/30:is(.dark *) { + background-color: color-mix(in srgb, var(--bg-gradient-mid) 30%, transparent) !important; +} + +/* 覆盖 Tailwind v3 的暗黑模式渐变色 */ +.dark\:from-gray-700:is(.dark *) { + --tw-gradient-from: var(--bg-gradient-mid) !important; + --tw-gradient-to: var(--bg-gradient-mid) !important; + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to) !important; +} + +.dark\:to-gray-800\/90:is(.dark *) { + --tw-gradient-to: var(--glass-strong-color) !important; +} + +/* 覆盖 Tailwind v3 的暗黑模式悬停背景色 */ +.dark\:hover\:bg-gray-600:is(.dark *):hover { + background-color: var(--bg-gradient-end) !important; +} + +.dark\:hover\:bg-gray-700:is(.dark *):hover { + background-color: var(--bg-gradient-mid) !important; +} + +.dark\:hover\:bg-gray-500:is(.dark *):hover { + background-color: var(--text-secondary) !important; +} + +.dark .border-gray-700, +.dark\:border-gray-700:is(.dark *) { + border-color: var(--border-color) !important; +} + +.dark .border-gray-600, +.dark\:border-gray-600:is(.dark *) { + border-color: var(--border-color) !important; +} + +/* 覆盖悬停边框色 */ +.dark\:hover\:border-gray-500:is(.dark *):hover { + border-color: var(--border-color) !important; +} + /* 优化后的transition - 避免布局跳动 */ button, input, @@ -99,9 +163,9 @@ body::before { right: 0; bottom: 0; background: - radial-gradient(circle at 20% 80%, rgba(240, 147, 251, 0.2) 0%, transparent 50%), - radial-gradient(circle at 80% 20%, rgba(102, 126, 234, 0.2) 0%, transparent 50%), - radial-gradient(circle at 40% 40%, rgba(118, 75, 162, 0.1) 0%, transparent 50%); + radial-gradient(circle at 20% 80%, rgba(var(--accent-rgb), 0.2) 0%, transparent 50%), + radial-gradient(circle at 80% 20%, rgba(var(--primary-rgb), 0.2) 0%, transparent 50%), + radial-gradient(circle at 40% 40%, rgba(var(--secondary-rgb), 0.1) 0%, transparent 50%); pointer-events: none; z-index: -1; } @@ -174,8 +238,8 @@ body::before { background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); color: white; box-shadow: - 0 10px 15px -3px rgba(102, 126, 234, 0.3), - 0 4px 6px -2px rgba(102, 126, 234, 0.05); + 0 10px 15px -3px rgba(var(--primary-rgb), 0.3), + 0 4px 6px -2px rgba(var(--primary-rgb), 0.05); transform: translateY(-1px); } @@ -215,15 +279,15 @@ body::before { background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); color: white; box-shadow: - 0 10px 15px -3px rgba(102, 126, 234, 0.3), - 0 4px 6px -2px rgba(102, 126, 234, 0.05); + 0 10px 15px -3px rgba(var(--primary-rgb), 0.3), + 0 4px 6px -2px rgba(var(--primary-rgb), 0.05); } .btn-primary:hover { transform: translateY(-1px); box-shadow: - 0 20px 25px -5px rgba(102, 126, 234, 0.3), - 0 10px 10px -5px rgba(102, 126, 234, 0.1); + 0 20px 25px -5px rgba(var(--primary-rgb), 0.3), + 0 10px 10px -5px rgba(var(--primary-rgb), 0.1); } .btn-success { @@ -275,7 +339,7 @@ body::before { outline: none; border-color: var(--primary-color); box-shadow: - 0 0 0 3px rgba(102, 126, 234, 0.1), + 0 0 0 3px rgba(var(--primary-rgb), 0.1), 0 10px 15px -3px rgba(0, 0, 0, 0.1); background: rgba(255, 255, 255, 0.95); color: #1f2937; @@ -283,9 +347,9 @@ body::before { .dark .form-input:focus { box-shadow: - 0 0 0 3px rgba(129, 140, 248, 0.2), + 0 0 0 3px rgba(var(--primary-rgb), 0.2), 0 10px 15px -3px rgba(0, 0, 0, 0.2); - background: rgba(17, 24, 39, 0.95); + background: var(--glass-strong-color); color: #f3f4f6; } @@ -332,19 +396,7 @@ body::before { } .dark .stat-card { - background: linear-gradient(135deg, rgba(31, 41, 55, 0.95) 0%, rgba(17, 24, 39, 0.8) 100%); -} - -.stat-card::before { - content: ''; - position: absolute; - top: -50%; - left: -50%; - width: 200%; - height: 200%; - background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%); - opacity: 0; - transition: opacity 0.3s ease; + background: linear-gradient(135deg, var(--glass-strong-color) 0%, var(--bg-gradient-start) 100%); } .stat-card:hover { @@ -354,10 +406,6 @@ body::before { 0 10px 10px -5px rgba(0, 0, 0, 0.04); } -.stat-card:hover::before { - opacity: 1; -} - .stat-icon { width: 56px; height: 56px; @@ -407,15 +455,15 @@ body::before { background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); color: white; box-shadow: - 0 10px 15px -3px rgba(102, 126, 234, 0.3), - 0 4px 6px -2px rgba(102, 126, 234, 0.05); + 0 10px 15px -3px rgba(var(--primary-rgb), 0.3), + 0 4px 6px -2px rgba(var(--primary-rgb), 0.05); } .btn-primary:hover { transform: translateY(-1px); box-shadow: - 0 20px 25px -5px rgba(102, 126, 234, 0.3), - 0 10px 10px -5px rgba(102, 126, 234, 0.1); + 0 20px 25px -5px rgba(var(--primary-rgb), 0.3), + 0 10px 10px -5px rgba(var(--primary-rgb), 0.1); } .btn-success { @@ -466,7 +514,7 @@ body::before { outline: none; border-color: var(--primary-color); box-shadow: - 0 0 0 3px rgba(102, 126, 234, 0.1), + 0 0 0 3px rgba(var(--primary-rgb), 0.1), 0 10px 15px -3px rgba(0, 0, 0, 0.1); background: rgba(255, 255, 255, 0.95); color: #1f2937; @@ -474,9 +522,9 @@ body::before { .dark .form-input:focus { box-shadow: - 0 0 0 3px rgba(129, 140, 248, 0.2), + 0 0 0 3px rgba(var(--primary-rgb), 0.2), 0 10px 15px -3px rgba(0, 0, 0, 0.2); - background: rgba(17, 24, 39, 0.95); + background: var(--glass-strong-color); color: #f3f4f6; } @@ -527,8 +575,8 @@ body::before { } .dark .modal-content { - background: #1f2937; - border: 1px solid rgba(75, 85, 99, 0.5); + background: var(--bg-gradient-start); + border: 1px solid var(--border-color); box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.02); @@ -633,11 +681,11 @@ body::before { /* 自定义滚动条样式 */ .custom-scrollbar { scrollbar-width: thin; - scrollbar-color: rgba(102, 126, 234, 0.3) rgba(102, 126, 234, 0.05); + scrollbar-color: rgba(var(--primary-rgb), 0.3) rgba(var(--primary-rgb), 0.05); } .dark .custom-scrollbar { - scrollbar-color: rgba(129, 140, 248, 0.3) rgba(129, 140, 248, 0.05); + scrollbar-color: rgba(var(--primary-rgb), 0.3) rgba(var(--primary-rgb), 0.05); } .custom-scrollbar::-webkit-scrollbar { @@ -646,38 +694,62 @@ body::before { } .custom-scrollbar::-webkit-scrollbar-track { - background: rgba(102, 126, 234, 0.05); + background: rgba(var(--primary-rgb), 0.05); border-radius: 10px; } .dark .custom-scrollbar::-webkit-scrollbar-track { - background: rgba(129, 140, 248, 0.05); + background: rgba(var(--primary-rgb), 0.05); } .custom-scrollbar::-webkit-scrollbar-thumb { - background: linear-gradient(135deg, rgba(102, 126, 234, 0.4) 0%, rgba(118, 75, 162, 0.4) 100%); + background: linear-gradient( + 135deg, + rgba(var(--primary-rgb), 0.4) 0%, + rgba(var(--secondary-rgb), 0.4) 100% + ); border-radius: 10px; transition: background 0.3s ease; } .dark .custom-scrollbar::-webkit-scrollbar-thumb { - background: linear-gradient(135deg, rgba(129, 140, 248, 0.4) 0%, rgba(167, 139, 250, 0.4) 100%); + background: linear-gradient( + 135deg, + rgba(var(--primary-rgb), 0.4) 0%, + rgba(var(--secondary-rgb), 0.4) 100% + ); } .custom-scrollbar::-webkit-scrollbar-thumb:hover { - background: linear-gradient(135deg, rgba(102, 126, 234, 0.6) 0%, rgba(118, 75, 162, 0.6) 100%); + background: linear-gradient( + 135deg, + rgba(var(--primary-rgb), 0.6) 0%, + rgba(var(--secondary-rgb), 0.6) 100% + ); } .dark .custom-scrollbar::-webkit-scrollbar-thumb:hover { - background: linear-gradient(135deg, rgba(129, 140, 248, 0.6) 0%, rgba(167, 139, 250, 0.6) 100%); + background: linear-gradient( + 135deg, + rgba(var(--primary-rgb), 0.6) 0%, + rgba(var(--secondary-rgb), 0.6) 100% + ); } .custom-scrollbar::-webkit-scrollbar-thumb:active { - background: linear-gradient(135deg, rgba(102, 126, 234, 0.8) 0%, rgba(118, 75, 162, 0.8) 100%); + background: linear-gradient( + 135deg, + rgba(var(--primary-rgb), 0.8) 0%, + rgba(var(--secondary-rgb), 0.8) 100% + ); } .dark .custom-scrollbar::-webkit-scrollbar-thumb:active { - background: linear-gradient(135deg, rgba(129, 140, 248, 0.8) 0%, rgba(167, 139, 250, 0.8) 100%); + background: linear-gradient( + 135deg, + rgba(var(--primary-rgb), 0.8) 0%, + rgba(var(--secondary-rgb), 0.8) 100% + ); } /* 弹窗滚动内容样式 */ @@ -690,8 +762,8 @@ body::before { @media (max-width: 768px) { .glass, .glass-strong { - margin: 16px; - border-radius: 20px; + margin: 0; + border-radius: 16px; } .stat-card { diff --git a/web/admin-spa/src/assets/styles/main.css b/web/admin-spa/src/assets/styles/main.css index 858bd7fb..c75ceb01 100644 --- a/web/admin-spa/src/assets/styles/main.css +++ b/web/admin-spa/src/assets/styles/main.css @@ -36,9 +36,9 @@ body::before { right: 0; bottom: 0; background: - radial-gradient(circle at 20% 80%, rgba(240, 147, 251, 0.2) 0%, transparent 50%), - radial-gradient(circle at 80% 20%, rgba(102, 126, 234, 0.2) 0%, transparent 50%), - radial-gradient(circle at 40% 40%, rgba(118, 75, 162, 0.1) 0%, transparent 50%); + radial-gradient(circle at 20% 80%, rgba(var(--accent-rgb), 0.2) 0%, transparent 50%), + radial-gradient(circle at 80% 20%, rgba(var(--primary-rgb), 0.2) 0%, transparent 50%), + radial-gradient(circle at 40% 40%, rgba(var(--secondary-rgb), 0.1) 0%, transparent 50%); pointer-events: none; z-index: -1; } @@ -80,7 +80,7 @@ h6 { /* 自定义滚动条样式 */ .custom-scrollbar { scrollbar-width: thin; - scrollbar-color: rgba(102, 126, 234, 0.3) rgba(102, 126, 234, 0.05); + scrollbar-color: rgba(var(--primary-rgb), 0.3) rgba(var(--primary-rgb), 0.05); } .custom-scrollbar::-webkit-scrollbar { @@ -89,22 +89,34 @@ h6 { } .custom-scrollbar::-webkit-scrollbar-track { - background: rgba(102, 126, 234, 0.05); + background: rgba(var(--primary-rgb), 0.05); border-radius: 10px; } .custom-scrollbar::-webkit-scrollbar-thumb { - background: linear-gradient(135deg, rgba(102, 126, 234, 0.4) 0%, rgba(118, 75, 162, 0.4) 100%); + background: linear-gradient( + 135deg, + rgba(var(--primary-rgb), 0.4) 0%, + rgba(var(--secondary-rgb), 0.4) 100% + ); border-radius: 10px; transition: background 0.3s ease; } .custom-scrollbar::-webkit-scrollbar-thumb:hover { - background: linear-gradient(135deg, rgba(102, 126, 234, 0.6) 0%, rgba(118, 75, 162, 0.6) 100%); + background: linear-gradient( + 135deg, + rgba(var(--primary-rgb), 0.6) 0%, + rgba(var(--secondary-rgb), 0.6) 100% + ); } .custom-scrollbar::-webkit-scrollbar-thumb:active { - background: linear-gradient(135deg, rgba(102, 126, 234, 0.8) 0%, rgba(118, 75, 162, 0.8) 100%); + background: linear-gradient( + 135deg, + rgba(var(--primary-rgb), 0.8) 0%, + rgba(var(--secondary-rgb), 0.8) 100% + ); } /* Vue过渡动画 */ @@ -137,8 +149,8 @@ h6 { @media (max-width: 768px) { .glass, .glass-strong { - margin: 16px; - border-radius: 20px; + margin: 0; + border-radius: 16px; } .stat-card { diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 1953e76d..d6e00ecd 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -3804,8 +3804,9 @@ diff --git a/web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue index 39eff929..acca4e1b 100644 --- a/web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue @@ -446,9 +446,9 @@ diff --git a/web/admin-spa/src/components/apikeys/RecordDetailModal.vue b/web/admin-spa/src/components/apikeys/RecordDetailModal.vue index 6a4b6482..dc327ac7 100644 --- a/web/admin-spa/src/components/apikeys/RecordDetailModal.vue +++ b/web/admin-spa/src/components/apikeys/RecordDetailModal.vue @@ -155,7 +155,7 @@ + + diff --git a/web/admin-spa/src/components/apistats/StatsOverview.vue b/web/admin-spa/src/components/apistats/StatsOverview.vue index d074d8f4..ae0a3f0f 100644 --- a/web/admin-spa/src/components/apistats/StatsOverview.vue +++ b/web/admin-spa/src/components/apistats/StatsOverview.vue @@ -1,7 +1,7 @@