diff --git a/.env.example b/.env.example index 2b6f8e1f..5c3f9f0b 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,8 @@ LOG_MAX_FILES=5 CLEANUP_INTERVAL=3600000 TOKEN_USAGE_RETENTION=2592000000 HEALTH_CHECK_INTERVAL=60000 +SYSTEM_TIMEZONE=Asia/Shanghai +TIMEZONE_OFFSET=8 # 🎨 Web 界面配置 WEB_TITLE=Claude Relay Service diff --git a/config/config.example.js b/config/config.example.js index ac7f5971..320c2a19 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -62,7 +62,9 @@ const config = { system: { cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL) || 3600000, // 1小时 tokenUsageRetention: parseInt(process.env.TOKEN_USAGE_RETENTION) || 2592000000, // 30天 - healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 60000 // 1分钟 + 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 }, // 🎨 Web界面配置 diff --git a/src/middleware/auth.js b/src/middleware/auth.js index df86b110..fdb448d6 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -109,7 +109,9 @@ const authenticateApiKey = async (req, res, next) => { name: validation.keyData.name, tokenLimit: validation.keyData.tokenLimit, claudeAccountId: validation.keyData.claudeAccountId, - concurrencyLimit: validation.keyData.concurrencyLimit + concurrencyLimit: validation.keyData.concurrencyLimit, + enableModelRestriction: validation.keyData.enableModelRestriction, + restrictedModels: validation.keyData.restrictedModels }; req.usage = validation.keyData.usage; diff --git a/src/models/redis.js b/src/models/redis.js index 2e324e49..45ce4fbb 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -2,6 +2,26 @@ const Redis = require('ioredis'); const config = require('../../config/config'); const logger = require('../utils/logger'); +// 时区辅助函数 +function getDateInTimezone(date = new Date()) { + const offset = config.system.timezoneOffset || 8; // 默认UTC+8 + const utcTime = date.getTime() + (date.getTimezoneOffset() * 60000); + const targetTime = new Date(utcTime + (offset * 3600000)); + return targetTime; +} + +// 获取配置时区的日期字符串 (YYYY-MM-DD) +function getDateStringInTimezone(date = new Date()) { + const tzDate = getDateInTimezone(date); + return `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}-${String(tzDate.getDate()).padStart(2, '0')}`; +} + +// 获取配置时区的小时 (0-23) +function getHourInTimezone(date = new Date()) { + const tzDate = getDateInTimezone(date); + return tzDate.getHours(); +} + class RedisClient { constructor() { this.client = null; @@ -140,9 +160,10 @@ class RedisClient { async incrementTokenUsage(keyId, tokens, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') { const key = `usage:${keyId}`; const now = new Date(); - const today = now.toISOString().split('T')[0]; - const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; - const currentHour = `${today}:${String(now.getHours()).padStart(2, '0')}`; // 新增小时级别 + const today = getDateStringInTimezone(now); + const tzDate = getDateInTimezone(now); + const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; + const currentHour = `${today}:${String(getHourInTimezone(now)).padStart(2, '0')}`; // 新增小时级别 const daily = `usage:daily:${keyId}:${today}`; const monthly = `usage:monthly:${keyId}:${currentMonth}`; @@ -263,9 +284,10 @@ class RedisClient { async getUsageStats(keyId) { const totalKey = `usage:${keyId}`; - const today = new Date().toISOString().split('T')[0]; + const today = getDateStringInTimezone(); const dailyKey = `usage:daily:${keyId}:${today}`; - const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`; + const tzDate = getDateInTimezone(); + const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; const monthlyKey = `usage:monthly:${keyId}:${currentMonth}`; const [total, daily, monthly] = await Promise.all([ @@ -534,7 +556,7 @@ class RedisClient { // 📊 获取今日系统统计 async getTodayStats() { try { - const today = new Date().toISOString().split('T')[0]; + const today = getDateStringInTimezone(); const dailyKeys = await this.client.keys(`usage:daily:*:${today}`); let totalRequestsToday = 0; diff --git a/src/routes/admin.js b/src/routes/admin.js index 703162ba..2978b28b 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -32,7 +32,9 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { tokenLimit, expiresAt, claudeAccountId, - concurrencyLimit + concurrencyLimit, + enableModelRestriction, + restrictedModels } = req.body; // 输入验证 @@ -57,13 +59,24 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { return res.status(400).json({ error: 'Concurrency limit must be a non-negative integer' }); } + // 验证模型限制字段 + if (enableModelRestriction !== undefined && typeof enableModelRestriction !== 'boolean') { + return res.status(400).json({ error: 'Enable model restriction must be a boolean' }); + } + + if (restrictedModels !== undefined && !Array.isArray(restrictedModels)) { + return res.status(400).json({ error: 'Restricted models must be an array' }); + } + const newKey = await apiKeyService.generateApiKey({ name, description, tokenLimit, expiresAt, claudeAccountId, - concurrencyLimit + concurrencyLimit, + enableModelRestriction, + restrictedModels }); logger.success(`🔑 Admin created new API key: ${name}`); @@ -78,9 +91,9 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { try { const { keyId } = req.params; - const { tokenLimit, concurrencyLimit, claudeAccountId } = req.body; + const { tokenLimit, concurrencyLimit, claudeAccountId, enableModelRestriction, restrictedModels } = req.body; - // 只允许更新tokenLimit、concurrencyLimit和claudeAccountId + // 只允许更新指定字段 const updates = {}; if (tokenLimit !== undefined && tokenLimit !== null && tokenLimit !== '') { @@ -102,6 +115,21 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { updates.claudeAccountId = claudeAccountId || ''; } + // 处理模型限制字段 + if (enableModelRestriction !== undefined) { + if (typeof enableModelRestriction !== 'boolean') { + return res.status(400).json({ error: 'Enable model restriction must be a boolean' }); + } + updates.enableModelRestriction = enableModelRestriction; + } + + if (restrictedModels !== undefined) { + if (!Array.isArray(restrictedModels)) { + return res.status(400).json({ error: 'Restricted models must be an array' }); + } + updates.restrictedModels = restrictedModels; + } + await apiKeyService.updateApiKey(keyId, updates); logger.success(`📝 Admin updated API key: ${keyId}`); diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index ee755ea5..d696a623 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -18,7 +18,9 @@ class ApiKeyService { expiresAt = null, claudeAccountId = null, isActive = true, - concurrencyLimit = 0 + concurrencyLimit = 0, + enableModelRestriction = false, + restrictedModels = [] } = options; // 生成简单的API Key (64字符十六进制) @@ -35,6 +37,8 @@ class ApiKeyService { concurrencyLimit: String(concurrencyLimit ?? 0), isActive: String(isActive), claudeAccountId: claudeAccountId || '', + enableModelRestriction: String(enableModelRestriction), + restrictedModels: JSON.stringify(restrictedModels || []), createdAt: new Date().toISOString(), lastUsedAt: '', expiresAt: expiresAt || '', @@ -55,6 +59,8 @@ class ApiKeyService { concurrencyLimit: parseInt(keyData.concurrencyLimit), isActive: keyData.isActive === 'true', claudeAccountId: keyData.claudeAccountId, + enableModelRestriction: keyData.enableModelRestriction === 'true', + restrictedModels: JSON.parse(keyData.restrictedModels), createdAt: keyData.createdAt, expiresAt: keyData.expiresAt, createdBy: keyData.createdBy @@ -102,6 +108,14 @@ class ApiKeyService { logger.api(`🔓 API key validated successfully: ${keyData.id}`); + // 解析限制模型数据 + let restrictedModels = []; + try { + restrictedModels = keyData.restrictedModels ? JSON.parse(keyData.restrictedModels) : []; + } catch (e) { + restrictedModels = []; + } + return { valid: true, keyData: { @@ -109,7 +123,9 @@ class ApiKeyService { name: keyData.name, claudeAccountId: keyData.claudeAccountId, tokenLimit: parseInt(keyData.tokenLimit), - concurrencyLimit: parseInt(keyData.concurrencyLimit || 0), + concurrencyLimit: parseInt(keyData.concurrencyLimit || 0), + enableModelRestriction: keyData.enableModelRestriction === 'true', + restrictedModels: restrictedModels, usage } }; @@ -131,6 +147,12 @@ class ApiKeyService { key.concurrencyLimit = parseInt(key.concurrencyLimit || 0); key.currentConcurrency = await redis.getConcurrency(key.id); key.isActive = key.isActive === 'true'; + key.enableModelRestriction = key.enableModelRestriction === 'true'; + try { + key.restrictedModels = key.restrictedModels ? JSON.parse(key.restrictedModels) : []; + } catch (e) { + key.restrictedModels = []; + } delete key.apiKey; // 不返回哈希后的key } @@ -150,12 +172,20 @@ class ApiKeyService { } // 允许更新的字段 - const allowedUpdates = ['name', 'description', 'tokenLimit', 'concurrencyLimit', 'isActive', 'claudeAccountId', 'expiresAt']; + const allowedUpdates = ['name', 'description', 'tokenLimit', 'concurrencyLimit', 'isActive', 'claudeAccountId', 'expiresAt', 'enableModelRestriction', 'restrictedModels']; const updatedData = { ...keyData }; for (const [field, value] of Object.entries(updates)) { if (allowedUpdates.includes(field)) { - updatedData[field] = (value != null ? value : '').toString(); + if (field === 'restrictedModels') { + // 特殊处理 restrictedModels 数组 + updatedData[field] = JSON.stringify(value || []); + } else if (field === 'enableModelRestriction') { + // 布尔值转字符串 + updatedData[field] = String(value); + } else { + updatedData[field] = (value != null ? value : '').toString(); + } } } diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index 5161b1cd..67179f2b 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -22,6 +22,34 @@ class ClaudeRelayService { let upstreamRequest = null; try { + // 调试日志:查看API Key数据 + logger.info(`🔍 API Key data received:`, { + apiKeyName: apiKeyData.name, + enableModelRestriction: apiKeyData.enableModelRestriction, + restrictedModels: apiKeyData.restrictedModels, + requestedModel: requestBody.model + }); + + // 检查模型限制 + if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels && apiKeyData.restrictedModels.length > 0) { + const requestedModel = requestBody.model; + logger.info(`🔒 Model restriction check - Requested model: ${requestedModel}, Restricted models: ${JSON.stringify(apiKeyData.restrictedModels)}`); + + if (requestedModel && apiKeyData.restrictedModels.includes(requestedModel)) { + logger.warn(`🚫 Model restriction violation for key ${apiKeyData.name}: Attempted to use restricted model ${requestedModel}`); + return { + statusCode: 403, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + error: { + type: 'forbidden', + message: '暂无该模型访问权限' + } + }) + }; + } + } + // 生成会话哈希用于sticky会话 const sessionHash = sessionHelper.generateSessionHash(requestBody); @@ -419,6 +447,36 @@ class ClaudeRelayService { // 🌊 处理流式响应(带usage数据捕获) async relayStreamRequestWithUsageCapture(requestBody, apiKeyData, responseStream, clientHeaders, usageCallback) { try { + // 调试日志:查看API Key数据(流式请求) + logger.info(`🔍 [Stream] API Key data received:`, { + apiKeyName: apiKeyData.name, + enableModelRestriction: apiKeyData.enableModelRestriction, + restrictedModels: apiKeyData.restrictedModels, + requestedModel: requestBody.model + }); + + // 检查模型限制 + if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels && apiKeyData.restrictedModels.length > 0) { + const requestedModel = requestBody.model; + logger.info(`🔒 [Stream] Model restriction check - Requested model: ${requestedModel}, Restricted models: ${JSON.stringify(apiKeyData.restrictedModels)}`); + + if (requestedModel && apiKeyData.restrictedModels.includes(requestedModel)) { + logger.warn(`🚫 Model restriction violation for key ${apiKeyData.name}: Attempted to use restricted model ${requestedModel}`); + + // 对于流式响应,需要写入错误并结束流 + const errorResponse = JSON.stringify({ + error: { + type: 'forbidden', + message: '暂无该模型访问权限' + } + }); + + responseStream.writeHead(403, { 'Content-Type': 'application/json' }); + responseStream.end(errorResponse); + return; + } + } + // 生成会话哈希用于sticky会话 const sessionHash = sessionHelper.generateSessionHash(requestBody); diff --git a/web/admin/app.js b/web/admin/app.js index 999f84e0..39de13f9 100644 --- a/web/admin/app.js +++ b/web/admin/app.js @@ -117,7 +117,10 @@ const app = createApp({ tokenLimit: '', description: '', concurrencyLimit: '', - claudeAccountId: '' + claudeAccountId: '', + enableModelRestriction: false, + restrictedModels: [], + modelInput: '' }, apiKeyModelStats: {}, // 存储每个key的模型统计数据 expandedApiKeys: {}, // 跟踪展开的API Keys @@ -155,7 +158,10 @@ const app = createApp({ name: '', tokenLimit: '', concurrencyLimit: '', - claudeAccountId: '' + claudeAccountId: '', + enableModelRestriction: false, + restrictedModels: [], + modelInput: '' }, // 账户 @@ -313,6 +319,20 @@ const app = createApp({ return this.apiKeys.filter(key => key.claudeAccountId === accountId).length; }, + // 添加限制模型 + addRestrictedModel(form) { + const model = form.modelInput.trim(); + if (model && !form.restrictedModels.includes(model)) { + form.restrictedModels.push(model); + form.modelInput = ''; + } + }, + + // 移除限制模型 + removeRestrictedModel(form, index) { + form.restrictedModels.splice(index, 1); + }, + // Toast 通知方法 showToast(message, type = 'info', title = null, duration = 5000) { const id = ++this.toastIdCounter; @@ -1191,7 +1211,9 @@ const app = createApp({ tokenLimit: this.apiKeyForm.tokenLimit && this.apiKeyForm.tokenLimit.trim() ? parseInt(this.apiKeyForm.tokenLimit) : null, description: this.apiKeyForm.description || '', concurrencyLimit: this.apiKeyForm.concurrencyLimit && this.apiKeyForm.concurrencyLimit.trim() ? parseInt(this.apiKeyForm.concurrencyLimit) : 0, - claudeAccountId: this.apiKeyForm.claudeAccountId || null + claudeAccountId: this.apiKeyForm.claudeAccountId || null, + enableModelRestriction: this.apiKeyForm.enableModelRestriction, + restrictedModels: this.apiKeyForm.restrictedModels }) }); @@ -1209,7 +1231,7 @@ const app = createApp({ // 关闭创建弹窗并清理表单 this.showCreateApiKeyModal = false; - this.apiKeyForm = { name: '', tokenLimit: '', description: '', concurrencyLimit: '', claudeAccountId: '' }; + this.apiKeyForm = { name: '', tokenLimit: '', description: '', concurrencyLimit: '', claudeAccountId: '', enableModelRestriction: false, restrictedModels: [], modelInput: '' }; // 重新加载API Keys列表 await this.loadApiKeys(); @@ -1253,7 +1275,10 @@ const app = createApp({ name: key.name, tokenLimit: key.tokenLimit || '', concurrencyLimit: key.concurrencyLimit || '', - claudeAccountId: key.claudeAccountId || '' + claudeAccountId: key.claudeAccountId || '', + enableModelRestriction: key.enableModelRestriction || false, + restrictedModels: key.restrictedModels ? [...key.restrictedModels] : [], + modelInput: '' }; this.showEditApiKeyModal = true; }, @@ -1265,7 +1290,10 @@ const app = createApp({ name: '', tokenLimit: '', concurrencyLimit: '', - claudeAccountId: '' + claudeAccountId: '', + enableModelRestriction: false, + restrictedModels: [], + modelInput: '' }; }, @@ -1281,7 +1309,9 @@ const app = createApp({ body: JSON.stringify({ tokenLimit: this.editApiKeyForm.tokenLimit && this.editApiKeyForm.tokenLimit.toString().trim() !== '' ? parseInt(this.editApiKeyForm.tokenLimit) : 0, concurrencyLimit: this.editApiKeyForm.concurrencyLimit && this.editApiKeyForm.concurrencyLimit.toString().trim() !== '' ? parseInt(this.editApiKeyForm.concurrencyLimit) : 0, - claudeAccountId: this.editApiKeyForm.claudeAccountId || null + claudeAccountId: this.editApiKeyForm.claudeAccountId || null, + enableModelRestriction: this.editApiKeyForm.enableModelRestriction, + restrictedModels: this.editApiKeyForm.restrictedModels }) }); diff --git a/web/admin/index.html b/web/admin/index.html index 6bdc9716..51a6c17c 100644 --- a/web/admin/index.html +++ b/web/admin/index.html @@ -1842,6 +1842,62 @@

选择专属账号后,此API Key将只使用该账号,不选择则使用共享账号池

+
+
+ + +
+ +
+
+ +
+ + {{ model }} + + + + 暂无限制的模型 + +
+
+ + +
+

设置此API Key无法访问的模型,例如:claude-opus-4-20250514

+
+
+
+
+
+
+ + +
+ +
+
+ +
+ + {{ model }} + + + + 暂无限制的模型 + +
+
+ + +
+

设置此API Key无法访问的模型,例如:claude-opus-4-20250514

+
+
+
+