| ${{ (key.dailyCost || 0).toFixed(4) }}
+
+ 总费用
+ ${{ (key.totalCost || 0).toFixed(4) }}
+
最后使用
{{
@@ -1590,8 +1613,8 @@ const apiKeyStatsTimeRange = ref('today')
const activeTab = ref('active')
const deletedApiKeys = ref([])
const deletedApiKeysLoading = ref(false)
-const apiKeysSortBy = ref('')
-const apiKeysSortOrder = ref('asc')
+const apiKeysSortBy = ref('dailyCost')
+const apiKeysSortOrder = ref('desc')
const expandedApiKeys = ref({})
const apiKeyModelStats = ref({})
const apiKeyDateFilters = ref({})
@@ -1696,9 +1719,12 @@ const sortedApiKeys = computed(() => {
if (apiKeysSortBy.value === 'status') {
aVal = a.isActive ? 1 : 0
bVal = b.isActive ? 1 : 0
- } else if (apiKeysSortBy.value === 'cost') {
- aVal = parseFloat(calculateApiKeyCost(a.usage).replace('$', ''))
- bVal = parseFloat(calculateApiKeyCost(b.usage).replace('$', ''))
+ } else if (apiKeysSortBy.value === 'dailyCost') {
+ aVal = a.dailyCost || 0
+ bVal = b.dailyCost || 0
+ } else if (apiKeysSortBy.value === 'totalCost') {
+ aVal = a.totalCost || 0
+ bVal = b.totalCost || 0
} else if (apiKeysSortBy.value === 'createdAt' || apiKeysSortBy.value === 'expiresAt') {
aVal = aVal ? new Date(aVal).getTime() : 0
bVal = bVal ? new Date(bVal).getTime() : 0
@@ -1883,13 +1909,6 @@ const formatNumber = (num) => {
return num.toLocaleString('zh-CN')
}
-// 计算API Key费用
-const calculateApiKeyCost = (usage) => {
- if (!usage || !usage.total) return '$0.0000'
- const cost = usage.total.cost || 0
- return `$${cost.toFixed(4)}`
-}
-
// 获取绑定账户名称
const getBoundAccountName = (accountId) => {
if (!accountId) return '未知账户'
From d8e833ef1a71836ab4ada3d9bdefd9bfa531a4fd Mon Sep 17 00:00:00 2001
From: iaineng
Date: Thu, 4 Sep 2025 23:26:18 +0800
Subject: [PATCH 05/15] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BC=9A?=
=?UTF-8?q?=E8=AF=9D=E7=B2=98=E6=80=A7=E6=9C=BA=E5=88=B6=E4=B8=8BPro?=
=?UTF-8?q?=E8=B4=A6=E6=88=B7=E8=A2=AB=E9=94=99=E8=AF=AF=E8=B0=83=E5=BA=A6?=
=?UTF-8?q?=E7=94=A8=E4=BA=8EOpus=E8=AF=B7=E6=B1=82=E7=9A=84=E9=97=AE?=
=?UTF-8?q?=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 在 _isAccountAvailable 方法中添加了模型兼容性检查,避免Pro账户被用于Opus请求
- 创建 _isModelSupportedByAccount 统一方法来处理模型兼容性验证
- 支持Claude OAuth账户的订阅类型检查(Pro/Free/Max)
- 支持Claude Console账户的supportedModels配置检查
---
src/services/unifiedClaudeScheduler.js | 180 +++++++++++++++----------
1 file changed, 111 insertions(+), 69 deletions(-)
diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js
index 34e61a81..e144e8c6 100644
--- a/src/services/unifiedClaudeScheduler.js
+++ b/src/services/unifiedClaudeScheduler.js
@@ -20,6 +20,77 @@ class UnifiedClaudeScheduler {
return schedulable !== false && schedulable !== 'false'
}
+ // 🔍 检查账户是否支持请求的模型
+ _isModelSupportedByAccount(account, accountType, requestedModel, context = '') {
+ if (!requestedModel) {
+ return true // 没有指定模型时,默认支持
+ }
+
+ // Claude OAuth 账户的 Opus 模型检查
+ if (accountType === 'claude-official') {
+ if (requestedModel.toLowerCase().includes('opus')) {
+ if (account.subscriptionInfo) {
+ try {
+ const info =
+ typeof account.subscriptionInfo === 'string'
+ ? JSON.parse(account.subscriptionInfo)
+ : account.subscriptionInfo
+
+ // Pro 和 Free 账号不支持 Opus
+ if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
+ logger.info(
+ `🚫 Claude account ${account.name} (Pro) does not support Opus model${context ? ` ${context}` : ''}`
+ )
+ return false
+ }
+ if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') {
+ logger.info(
+ `🚫 Claude account ${account.name} (${info.accountType}) does not support Opus model${context ? ` ${context}` : ''}`
+ )
+ return false
+ }
+ } catch (e) {
+ // 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max)
+ logger.debug(
+ `Account ${account.name} has invalid subscriptionInfo${context ? ` ${context}` : ''}, assuming Max`
+ )
+ }
+ }
+ // 没有订阅信息的账号,默认当作支持(兼容旧数据)
+ }
+ }
+
+ // Claude Console 账户的模型支持检查
+ if (accountType === 'claude-console' && account.supportedModels) {
+ // 兼容旧格式(数组)和新格式(对象)
+ if (Array.isArray(account.supportedModels)) {
+ // 旧格式:数组
+ if (
+ account.supportedModels.length > 0 &&
+ !account.supportedModels.includes(requestedModel)
+ ) {
+ logger.info(
+ `🚫 Claude Console account ${account.name} does not support model ${requestedModel}${context ? ` ${context}` : ''}`
+ )
+ return false
+ }
+ } else if (typeof account.supportedModels === 'object') {
+ // 新格式:映射表
+ if (
+ Object.keys(account.supportedModels).length > 0 &&
+ !claudeConsoleAccountService.isModelSupported(account.supportedModels, requestedModel)
+ ) {
+ logger.info(
+ `🚫 Claude Console account ${account.name} does not support model ${requestedModel}${context ? ` ${context}` : ''}`
+ )
+ return false
+ }
+ }
+ }
+
+ return true
+ }
+
// 🎯 统一调度Claude账号(官方和Console)
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
try {
@@ -102,7 +173,8 @@ class UnifiedClaudeScheduler {
// 验证映射的账户是否仍然可用
const isAvailable = await this._isAccountAvailable(
mappedAccount.accountId,
- mappedAccount.accountType
+ mappedAccount.accountType,
+ requestedModel
)
if (isAvailable) {
logger.info(
@@ -269,33 +341,9 @@ class UnifiedClaudeScheduler {
) {
// 检查是否可调度
- // 检查模型支持(如果请求的是 Opus 模型)
- if (requestedModel && requestedModel.toLowerCase().includes('opus')) {
- // 检查账号的订阅信息
- if (account.subscriptionInfo) {
- try {
- const info =
- typeof account.subscriptionInfo === 'string'
- ? JSON.parse(account.subscriptionInfo)
- : account.subscriptionInfo
-
- // Pro 和 Free 账号不支持 Opus
- if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
- logger.info(`🚫 Claude account ${account.name} (Pro) does not support Opus model`)
- continue // Claude Pro 不支持 Opus
- }
- if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') {
- logger.info(
- `🚫 Claude account ${account.name} (${info.accountType}) does not support Opus model`
- )
- continue // 明确标记为 Pro 或 Free 的账号不支持
- }
- } catch (e) {
- // 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max)
- logger.debug(`Account ${account.name} has invalid subscriptionInfo, assuming Max`)
- }
- }
- // 没有订阅信息的账号,默认当作支持(兼容旧数据)
+ // 检查模型支持
+ if (!this._isModelSupportedByAccount(account, 'claude-official', requestedModel)) {
+ continue
}
// 检查是否被限流
@@ -330,32 +378,9 @@ class UnifiedClaudeScheduler {
) {
// 检查是否可调度
- // 检查模型支持(如果有请求的模型)
- if (requestedModel && account.supportedModels) {
- // 兼容旧格式(数组)和新格式(对象)
- if (Array.isArray(account.supportedModels)) {
- // 旧格式:数组
- if (
- account.supportedModels.length > 0 &&
- !account.supportedModels.includes(requestedModel)
- ) {
- logger.info(
- `🚫 Claude Console account ${account.name} does not support model ${requestedModel}`
- )
- continue
- }
- } else if (typeof account.supportedModels === 'object') {
- // 新格式:映射表
- if (
- Object.keys(account.supportedModels).length > 0 &&
- !claudeConsoleAccountService.isModelSupported(account.supportedModels, requestedModel)
- ) {
- logger.info(
- `🚫 Claude Console account ${account.name} does not support model ${requestedModel}`
- )
- continue
- }
- }
+ // 检查模型支持
+ if (!this._isModelSupportedByAccount(account, 'claude-console', requestedModel)) {
+ continue
}
// 检查是否被限流
@@ -439,7 +464,7 @@ class UnifiedClaudeScheduler {
}
// 🔍 检查账户是否可用
- async _isAccountAvailable(accountId, accountType) {
+ async _isAccountAvailable(accountId, accountType, requestedModel = null) {
try {
if (accountType === 'claude-official') {
const account = await redis.getClaudeAccount(accountId)
@@ -456,6 +481,19 @@ class UnifiedClaudeScheduler {
logger.info(`🚫 Account ${accountId} is not schedulable`)
return false
}
+
+ // 检查模型兼容性
+ if (
+ !this._isModelSupportedByAccount(
+ account,
+ 'claude-official',
+ requestedModel,
+ 'in session check'
+ )
+ ) {
+ return false
+ }
+
return !(await claudeAccountService.isAccountRateLimited(accountId))
} else if (accountType === 'claude-console') {
const account = await claudeConsoleAccountService.getAccount(accountId)
@@ -475,6 +513,19 @@ class UnifiedClaudeScheduler {
logger.info(`🚫 Claude Console account ${accountId} is not schedulable`)
return false
}
+
+ // 检查模型支持
+ if (
+ !this._isModelSupportedByAccount(
+ account,
+ 'claude-console',
+ requestedModel,
+ 'in session check'
+ )
+ ) {
+ return false
+ }
+
// 检查是否被限流
if (await claudeConsoleAccountService.isAccountRateLimited(accountId)) {
return false
@@ -693,7 +744,8 @@ class UnifiedClaudeScheduler {
if (memberIds.includes(mappedAccount.accountId)) {
const isAvailable = await this._isAccountAvailable(
mappedAccount.accountId,
- mappedAccount.accountType
+ mappedAccount.accountType,
+ requestedModel
)
if (isAvailable) {
logger.info(
@@ -756,19 +808,9 @@ class UnifiedClaudeScheduler {
: account.status === 'active'
if (isActive && status && this._isSchedulable(account.schedulable)) {
- // 检查模型支持(Console账户)
- if (
- accountType === 'claude-console' &&
- requestedModel &&
- account.supportedModels &&
- account.supportedModels.length > 0
- ) {
- if (!account.supportedModels.includes(requestedModel)) {
- logger.info(
- `🚫 Account ${account.name} in group does not support model ${requestedModel}`
- )
- continue
- }
+ // 检查模型支持
+ if (!this._isModelSupportedByAccount(account, accountType, requestedModel, 'in group')) {
+ continue
}
// 检查是否被限流
From 8c158d82fae4b25614e6623c0b3e9a0cea198caa Mon Sep 17 00:00:00 2001
From: iaineng
Date: Fri, 5 Sep 2025 12:18:33 +0800
Subject: [PATCH 06/15] =?UTF-8?q?fix:=20=E6=B7=BB=E5=8A=A0=E5=88=9B?=
=?UTF-8?q?=E5=BB=BAClaude=E8=B4=A6=E6=88=B7=E6=97=B6=E7=BC=BA=E5=A4=B1?=
=?UTF-8?q?=E7=9A=84useUnifiedUserAgent=E5=AD=97=E6=AE=B5=E5=A4=84?=
=?UTF-8?q?=E7=90=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 在 /admin/claude-accounts POST 路由中添加 useUnifiedUserAgent 参数解构
- 将 useUnifiedUserAgent 参数传递给 claudeAccountService.createAccount() 方法
- 保持与前端 AccountForm.vue 和服务层 claudeAccountService.js 的一致性
---
src/routes/admin.js | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/routes/admin.js b/src/routes/admin.js
index f29a43b9..6cdc3bb8 100644
--- a/src/routes/admin.js
+++ b/src/routes/admin.js
@@ -1902,7 +1902,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
priority,
groupId,
groupIds,
- autoStopOnWarning
+ autoStopOnWarning,
+ useUnifiedUserAgent
} = req.body
if (!name) {
@@ -1942,7 +1943,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
accountType: accountType || 'shared', // 默认为共享类型
platform,
priority: priority || 50, // 默认优先级为50
- autoStopOnWarning: autoStopOnWarning === true // 默认为false
+ autoStopOnWarning: autoStopOnWarning === true, // 默认为false
+ useUnifiedUserAgent: useUnifiedUserAgent === true // 默认为false
})
// 如果是分组类型,将账户添加到分组
From 4cc937a144ba3dca35cc406a9d26a16ee2fee4c9 Mon Sep 17 00:00:00 2001
From: sususu
Date: Fri, 5 Sep 2025 14:58:59 +0800
Subject: [PATCH 07/15] =?UTF-8?q?feat(Claude=20Console):=20=E6=B7=BB?=
=?UTF-8?q?=E5=8A=A0Claude=20Console=E8=B4=A6=E5=8F=B7=E6=AF=8F=E6=97=A5?=
=?UTF-8?q?=E9=85=8D=E9=A2=9D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
1. 额度检查优先级更高:即使不启用限流机制,超额仍会禁用账户
2. 状态会被覆盖:quota_exceeded 会覆盖 rate_limited
3. 两种恢复时间:
- 限流恢复:分钟级(如60分钟)
- 额度恢复:天级(第二天重置)
4. 独立控制:
- rateLimitDuration = 0:只管理额度,忽略429
- rateLimitDuration > 0:同时管理限流和额度
---
src/routes/admin.js | 58 ++-
src/services/claudeConsoleAccountService.js | 339 +++++++++++++++++-
src/services/claudeConsoleRelayService.js | 13 +
src/services/unifiedClaudeScheduler.js | 39 +-
.../src/components/accounts/AccountForm.vue | 168 ++++++++-
web/admin-spa/src/views/AccountsView.vue | 61 ++++
6 files changed, 656 insertions(+), 22 deletions(-)
diff --git a/src/routes/admin.js b/src/routes/admin.js
index f29a43b9..62540a8c 100644
--- a/src/routes/admin.js
+++ b/src/routes/admin.js
@@ -2292,7 +2292,9 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
rateLimitDuration,
proxy,
accountType,
- groupId
+ groupId,
+ dailyQuota,
+ quotaResetTime
} = req.body
if (!name || !apiUrl || !apiKey) {
@@ -2327,7 +2329,9 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
rateLimitDuration:
rateLimitDuration !== undefined && rateLimitDuration !== null ? rateLimitDuration : 60,
proxy,
- accountType: accountType || 'shared'
+ accountType: accountType || 'shared',
+ dailyQuota: dailyQuota || 0,
+ quotaResetTime: quotaResetTime || '00:00'
})
// 如果是分组类型,将账户添加到分组
@@ -2506,6 +2510,56 @@ router.put(
}
)
+// 获取Claude Console账户的使用统计
+router.get('/claude-console-accounts/:accountId/usage', authenticateAdmin, async (req, res) => {
+ try {
+ const { accountId } = req.params
+ const usageStats = await claudeConsoleAccountService.getAccountUsageStats(accountId)
+
+ if (!usageStats) {
+ return res.status(404).json({ error: 'Account not found' })
+ }
+
+ return res.json(usageStats)
+ } catch (error) {
+ logger.error('❌ Failed to get Claude Console account usage stats:', error)
+ return res.status(500).json({ error: 'Failed to get usage stats', message: error.message })
+ }
+})
+
+// 手动重置Claude Console账户的每日使用量
+router.post(
+ '/claude-console-accounts/:accountId/reset-usage',
+ authenticateAdmin,
+ async (req, res) => {
+ try {
+ const { accountId } = req.params
+ await claudeConsoleAccountService.resetDailyUsage(accountId)
+
+ logger.success(`✅ Admin manually reset daily usage for Claude Console account: ${accountId}`)
+ return res.json({ success: true, message: 'Daily usage reset successfully' })
+ } catch (error) {
+ logger.error('❌ Failed to reset Claude Console account daily usage:', error)
+ return res.status(500).json({ error: 'Failed to reset daily usage', message: error.message })
+ }
+ }
+)
+
+// 手动重置所有Claude Console账户的每日使用量
+router.post('/claude-console-accounts/reset-all-usage', authenticateAdmin, async (req, res) => {
+ try {
+ await claudeConsoleAccountService.resetAllDailyUsage()
+
+ logger.success('✅ Admin manually reset daily usage for all Claude Console accounts')
+ return res.json({ success: true, message: 'All daily usage reset successfully' })
+ } catch (error) {
+ logger.error('❌ Failed to reset all Claude Console accounts daily usage:', error)
+ return res
+ .status(500)
+ .json({ error: 'Failed to reset all daily usage', message: error.message })
+ }
+})
+
// ☁️ Bedrock 账户管理
// 获取所有Bedrock账户
diff --git a/src/services/claudeConsoleAccountService.js b/src/services/claudeConsoleAccountService.js
index 28be976d..34c9a5c7 100644
--- a/src/services/claudeConsoleAccountService.js
+++ b/src/services/claudeConsoleAccountService.js
@@ -50,7 +50,9 @@ class ClaudeConsoleAccountService {
proxy = null,
isActive = true,
accountType = 'shared', // 'dedicated' or 'shared'
- schedulable = true // 是否可被调度
+ schedulable = true, // 是否可被调度
+ dailyQuota = 0, // 每日额度限制(美元),0表示不限制
+ quotaResetTime = '00:00' // 额度重置时间(HH:mm格式)
} = options
// 验证必填字段
@@ -85,7 +87,14 @@ class ClaudeConsoleAccountService {
rateLimitedAt: '',
rateLimitStatus: '',
// 调度控制
- schedulable: schedulable.toString()
+ schedulable: schedulable.toString(),
+ // 额度管理相关
+ dailyQuota: dailyQuota.toString(), // 每日额度限制(美元)
+ dailyUsage: '0', // 当日使用金额(美元)
+ // 使用与统计一致的时区日期,避免边界问题
+ lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
+ quotaResetTime, // 额度重置时间
+ quotaStoppedAt: '' // 因额度停用的时间
}
const client = redis.getClientSafe()
@@ -116,7 +125,12 @@ class ClaudeConsoleAccountService {
proxy,
accountType,
status: 'active',
- createdAt: accountData.createdAt
+ createdAt: accountData.createdAt,
+ dailyQuota,
+ dailyUsage: 0,
+ lastResetDate: accountData.lastResetDate,
+ quotaResetTime,
+ quotaStoppedAt: null
}
}
@@ -148,12 +162,18 @@ class ClaudeConsoleAccountService {
isActive: accountData.isActive === 'true',
proxy: accountData.proxy ? JSON.parse(accountData.proxy) : null,
accountType: accountData.accountType || 'shared',
- status: accountData.status,
- errorMessage: accountData.errorMessage,
createdAt: accountData.createdAt,
lastUsedAt: accountData.lastUsedAt,
- rateLimitStatus: rateLimitInfo,
- schedulable: accountData.schedulable !== 'false' // 默认为true,只有明确设置为false才不可调度
+ status: accountData.status || 'active',
+ errorMessage: accountData.errorMessage,
+ rateLimitInfo,
+ schedulable: accountData.schedulable !== 'false', // 默认为true,只有明确设置为false才不可调度
+ // 额度管理相关
+ dailyQuota: parseFloat(accountData.dailyQuota || '0'),
+ dailyUsage: parseFloat(accountData.dailyUsage || '0'),
+ lastResetDate: accountData.lastResetDate || '',
+ quotaResetTime: accountData.quotaResetTime || '00:00',
+ quotaStoppedAt: accountData.quotaStoppedAt || null
})
}
}
@@ -267,6 +287,23 @@ class ClaudeConsoleAccountService {
updatedData.schedulable = updates.schedulable.toString()
}
+ // 额度管理相关字段
+ if (updates.dailyQuota !== undefined) {
+ updatedData.dailyQuota = updates.dailyQuota.toString()
+ }
+ if (updates.quotaResetTime !== undefined) {
+ updatedData.quotaResetTime = updates.quotaResetTime
+ }
+ if (updates.dailyUsage !== undefined) {
+ updatedData.dailyUsage = updates.dailyUsage.toString()
+ }
+ if (updates.lastResetDate !== undefined) {
+ updatedData.lastResetDate = updates.lastResetDate
+ }
+ if (updates.quotaStoppedAt !== undefined) {
+ updatedData.quotaStoppedAt = updates.quotaStoppedAt
+ }
+
// 处理账户类型变更
if (updates.accountType && updates.accountType !== existingAccount.accountType) {
updatedData.accountType = updates.accountType
@@ -361,7 +398,16 @@ class ClaudeConsoleAccountService {
const updates = {
rateLimitedAt: new Date().toISOString(),
- rateLimitStatus: 'limited'
+ rateLimitStatus: 'limited',
+ isActive: 'false', // 禁用账户
+ errorMessage: `Rate limited at ${new Date().toISOString()}`
+ }
+
+ // 只有当前状态不是quota_exceeded时才设置为rate_limited
+ // 避免覆盖更重要的配额超限状态
+ const currentStatus = await client.hget(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'status')
+ if (currentStatus !== 'quota_exceeded') {
+ updates.status = 'rate_limited'
}
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates)
@@ -376,7 +422,7 @@ class ClaudeConsoleAccountService {
platform: 'claude-console',
status: 'error',
errorCode: 'CLAUDE_CONSOLE_RATE_LIMITED',
- reason: `Account rate limited (429 error). ${account.rateLimitDuration ? `Will be blocked for ${account.rateLimitDuration} hours` : 'Temporary rate limit'}`,
+ reason: `Account rate limited (429 error) and has been disabled. ${account.rateLimitDuration ? `Will be automatically re-enabled after ${account.rateLimitDuration} minutes` : 'Manual intervention required to re-enable'}`,
timestamp: getISOStringWithTimezone(new Date())
})
} catch (webhookError) {
@@ -397,14 +443,40 @@ class ClaudeConsoleAccountService {
async removeAccountRateLimit(accountId) {
try {
const client = redis.getClientSafe()
+ const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
- await client.hdel(
- `${this.ACCOUNT_KEY_PREFIX}${accountId}`,
- 'rateLimitedAt',
- 'rateLimitStatus'
+ // 获取账户当前状态和额度信息
+ const [currentStatus, quotaStoppedAt] = await client.hmget(
+ accountKey,
+ 'status',
+ 'quotaStoppedAt'
)
- logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`)
+ // 删除限流相关字段
+ await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus')
+
+ // 根据不同情况决定是否恢复账户
+ if (currentStatus === 'rate_limited') {
+ if (quotaStoppedAt) {
+ // 还有额度限制,改为quota_exceeded状态
+ await client.hset(accountKey, {
+ status: 'quota_exceeded'
+ // isActive保持false
+ })
+ logger.info(`⚠️ Rate limit removed but quota exceeded remains for account: ${accountId}`)
+ } else {
+ // 没有额度限制,完全恢复
+ await client.hset(accountKey, {
+ isActive: 'true',
+ status: 'active',
+ errorMessage: ''
+ })
+ logger.success(`✅ Rate limit removed and account re-enabled: ${accountId}`)
+ }
+ } else {
+ logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`)
+ }
+
return { success: true }
} catch (error) {
logger.error(`❌ Failed to remove rate limit for Claude Console account: ${accountId}`, error)
@@ -454,6 +526,64 @@ class ClaudeConsoleAccountService {
}
}
+ // 🔍 检查账号是否因额度超限而被停用(懒惰检查)
+ async isAccountQuotaExceeded(accountId) {
+ try {
+ const account = await this.getAccount(accountId)
+ if (!account) {
+ return false
+ }
+
+ // 如果没有设置额度限制,不会超额
+ const dailyQuota = parseFloat(account.dailyQuota || '0')
+ if (isNaN(dailyQuota) || dailyQuota <= 0) {
+ return false
+ }
+
+ // 如果账户没有被额度停用,检查当前使用情况
+ if (!account.quotaStoppedAt) {
+ return false
+ }
+
+ // 检查是否应该重置额度(到了新的重置时间点)
+ if (this._shouldResetQuota(account)) {
+ await this.resetDailyUsage(accountId)
+ return false
+ }
+
+ // 仍在额度超限状态
+ return true
+ } catch (error) {
+ logger.error(
+ `❌ Failed to check quota exceeded status for Claude Console account: ${accountId}`,
+ error
+ )
+ return false
+ }
+ }
+
+ // 🔍 判断是否应该重置账户额度
+ _shouldResetQuota(account) {
+ // 与 Redis 统计一致:按配置时区判断“今天”与时间点
+ const tzNow = redis.getDateInTimezone(new Date())
+ const today = redis.getDateStringInTimezone(tzNow)
+
+ // 如果已经是今天重置过的,不需要重置
+ if (account.lastResetDate === today) {
+ return false
+ }
+
+ // 检查是否到了重置时间点(按配置时区的小时/分钟)
+ const resetTime = account.quotaResetTime || '00:00'
+ const [resetHour, resetMinute] = resetTime.split(':').map((n) => parseInt(n))
+
+ const currentHour = tzNow.getUTCHours()
+ const currentMinute = tzNow.getUTCMinutes()
+
+ // 如果当前时间已过重置时间且不是同一天重置的,应该重置
+ return currentHour > resetHour || (currentHour === resetHour && currentMinute >= resetMinute)
+ }
+
// 🚫 标记账号为未授权状态(401错误)
async markAccountUnauthorized(accountId) {
try {
@@ -820,6 +950,187 @@ class ClaudeConsoleAccountService {
// 返回映射后的模型,如果不存在则返回原模型
return modelMapping[requestedModel] || requestedModel
}
+
+ // 💰 检查账户使用额度(基于实时统计数据)
+ async checkQuotaUsage(accountId) {
+ try {
+ // 获取实时的使用统计(包含费用)
+ const usageStats = await redis.getAccountUsageStats(accountId)
+ const currentDailyCost = usageStats.daily.cost || 0
+
+ // 获取账户配置
+ const accountData = await this.getAccount(accountId)
+ if (!accountData) {
+ logger.warn(`Account not found: ${accountId}`)
+ return
+ }
+
+ // 解析额度配置,确保数值有效
+ const dailyQuota = parseFloat(accountData.dailyQuota || '0')
+ if (isNaN(dailyQuota) || dailyQuota <= 0) {
+ // 没有设置有效额度,无需检查
+ return
+ }
+
+ // 检查是否已经因额度停用(避免重复操作)
+ if (!accountData.isActive && accountData.quotaStoppedAt) {
+ return
+ }
+
+ // 检查是否超过额度限制
+ if (currentDailyCost >= dailyQuota) {
+ // 使用原子操作避免竞态条件 - 再次检查是否已设置quotaStoppedAt
+ const client = redis.getClientSafe()
+ const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
+
+ // double-check locking pattern - 检查quotaStoppedAt而不是status
+ const existingQuotaStop = await client.hget(accountKey, 'quotaStoppedAt')
+ if (existingQuotaStop) {
+ return // 已经被其他进程处理
+ }
+
+ // 超过额度,停用账户
+ const updates = {
+ isActive: false,
+ quotaStoppedAt: new Date().toISOString(),
+ errorMessage: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}`
+ }
+
+ // 只有当前状态是active时才改为quota_exceeded
+ // 如果是rate_limited等其他状态,保持原状态不变
+ const currentStatus = await client.hget(accountKey, 'status')
+ if (currentStatus === 'active') {
+ updates.status = 'quota_exceeded'
+ }
+
+ await this.updateAccount(accountId, updates)
+
+ logger.warn(
+ `💰 Account ${accountId} exceeded daily quota: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}`
+ )
+
+ // 发送webhook通知
+ try {
+ const webhookNotifier = require('../utils/webhookNotifier')
+ await webhookNotifier.sendAccountAnomalyNotification({
+ accountId,
+ accountName: accountData.name || 'Unknown Account',
+ platform: 'claude-console',
+ status: 'quota_exceeded',
+ errorCode: 'CLAUDE_CONSOLE_QUOTA_EXCEEDED',
+ reason: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}`
+ })
+ } catch (webhookError) {
+ logger.error('Failed to send webhook notification for quota exceeded:', webhookError)
+ }
+ }
+
+ logger.debug(
+ `💰 Quota check for account ${accountId}: $${currentDailyCost.toFixed(4)} / $${dailyQuota.toFixed(2)}`
+ )
+ } catch (error) {
+ logger.error('Failed to check quota usage:', error)
+ }
+ }
+
+ // 🔄 重置账户每日使用量(恢复因额度停用的账户)
+ async resetDailyUsage(accountId) {
+ try {
+ const accountData = await this.getAccount(accountId)
+ if (!accountData) {
+ return
+ }
+
+ const today = redis.getDateStringInTimezone()
+ const updates = {
+ lastResetDate: today
+ }
+
+ // 如果账户是因为超额被停用的,恢复账户
+ // 注意:状态可能是 quota_exceeded 或 rate_limited(如果429错误时也超额了)
+ if (
+ accountData.quotaStoppedAt &&
+ accountData.isActive === false &&
+ (accountData.status === 'quota_exceeded' || accountData.status === 'rate_limited')
+ ) {
+ updates.isActive = true
+ updates.status = 'active'
+ updates.errorMessage = ''
+ updates.quotaStoppedAt = ''
+
+ // 如果是rate_limited状态,也清除限流相关字段
+ if (accountData.status === 'rate_limited') {
+ const client = redis.getClientSafe()
+ const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
+ await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus')
+ }
+
+ logger.info(
+ `✅ Restored account ${accountId} after daily reset (was ${accountData.status})`
+ )
+ }
+
+ await this.updateAccount(accountId, updates)
+
+ logger.debug(`🔄 Reset daily usage for account ${accountId}`)
+ } catch (error) {
+ logger.error('Failed to reset daily usage:', error)
+ }
+ }
+
+ // 🔄 重置所有账户的每日使用量
+ async resetAllDailyUsage() {
+ try {
+ const accounts = await this.getAllAccounts()
+ // 与统计一致使用配置时区日期
+ const today = redis.getDateStringInTimezone()
+ let resetCount = 0
+
+ for (const account of accounts) {
+ // 只重置需要重置的账户
+ if (account.lastResetDate !== today) {
+ await this.resetDailyUsage(account.id)
+ resetCount += 1
+ }
+ }
+
+ logger.success(`✅ Reset daily usage for ${resetCount} Claude Console accounts`)
+ } catch (error) {
+ logger.error('Failed to reset all daily usage:', error)
+ }
+ }
+
+ // 📊 获取账户使用统计(基于实时数据)
+ async getAccountUsageStats(accountId) {
+ try {
+ // 获取实时的使用统计(包含费用)
+ const usageStats = await redis.getAccountUsageStats(accountId)
+ const currentDailyCost = usageStats.daily.cost || 0
+
+ // 获取账户配置
+ const accountData = await this.getAccount(accountId)
+ if (!accountData) {
+ return null
+ }
+
+ const dailyQuota = parseFloat(accountData.dailyQuota || '0')
+
+ return {
+ dailyQuota,
+ dailyUsage: currentDailyCost, // 使用实时计算的费用
+ remainingQuota: dailyQuota > 0 ? Math.max(0, dailyQuota - currentDailyCost) : null,
+ usagePercentage: dailyQuota > 0 ? (currentDailyCost / dailyQuota) * 100 : 0,
+ lastResetDate: accountData.lastResetDate,
+ quotaStoppedAt: accountData.quotaStoppedAt,
+ isQuotaExceeded: dailyQuota > 0 && currentDailyCost >= dailyQuota,
+ // 额外返回完整的使用统计
+ fullUsageStats: usageStats
+ }
+ } catch (error) {
+ logger.error('Failed to get account usage stats:', error)
+ return null
+ }
+ }
}
module.exports = new ClaudeConsoleAccountService()
diff --git a/src/services/claudeConsoleRelayService.js b/src/services/claudeConsoleRelayService.js
index 27920a47..9785bdd0 100644
--- a/src/services/claudeConsoleRelayService.js
+++ b/src/services/claudeConsoleRelayService.js
@@ -181,6 +181,11 @@ class ClaudeConsoleRelayService {
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
} else if (response.status === 429) {
logger.warn(`🚫 Rate limit detected for Claude Console account ${accountId}`)
+ // 收到429先检查是否因为超过了手动配置的每日额度
+ await claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
+ logger.error('❌ Failed to check quota after 429 error:', err)
+ })
+
await claudeConsoleAccountService.markAccountRateLimited(accountId)
} else if (response.status === 529) {
logger.warn(`🚫 Overload error detected for Claude Console account ${accountId}`)
@@ -377,6 +382,10 @@ class ClaudeConsoleRelayService {
claudeConsoleAccountService.markAccountUnauthorized(accountId)
} else if (response.status === 429) {
claudeConsoleAccountService.markAccountRateLimited(accountId)
+ // 检查是否因为超过每日额度
+ claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
+ logger.error('❌ Failed to check quota after 429 error:', err)
+ })
} else if (response.status === 529) {
claudeConsoleAccountService.markAccountOverloaded(accountId)
}
@@ -589,6 +598,10 @@ class ClaudeConsoleRelayService {
claudeConsoleAccountService.markAccountUnauthorized(accountId)
} else if (error.response.status === 429) {
claudeConsoleAccountService.markAccountRateLimited(accountId)
+ // 检查是否因为超过每日额度
+ claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
+ logger.error('❌ Failed to check quota after 429 error:', err)
+ })
} else if (error.response.status === 529) {
claudeConsoleAccountService.markAccountOverloaded(accountId)
}
diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js
index 34e61a81..8b247d97 100644
--- a/src/services/unifiedClaudeScheduler.js
+++ b/src/services/unifiedClaudeScheduler.js
@@ -209,10 +209,20 @@ class UnifiedClaudeScheduler {
boundConsoleAccount.isActive === true &&
boundConsoleAccount.status === 'active'
) {
+ // 主动触发一次额度检查
+ try {
+ await claudeConsoleAccountService.checkQuotaUsage(boundConsoleAccount.id)
+ } catch (e) {}
+
+ // 检查限流状态和额度状态
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(
boundConsoleAccount.id
)
- if (!isRateLimited) {
+ const isQuotaExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded(
+ boundConsoleAccount.id
+ )
+
+ if (!isRateLimited && !isQuotaExceeded) {
logger.info(
`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId})`
)
@@ -358,9 +368,16 @@ class UnifiedClaudeScheduler {
}
}
- // 检查是否被限流
+ // 主动触发一次额度检查,确保状态即时生效
+ try {
+ await claudeConsoleAccountService.checkQuotaUsage(account.id)
+ } catch (e) {}
+
+ // 检查是否被限流或额度超限
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(account.id)
- if (!isRateLimited) {
+ const isQuotaExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded(account.id)
+
+ if (!isRateLimited && !isQuotaExceeded) {
availableAccounts.push({
...account,
accountId: account.id,
@@ -372,7 +389,12 @@ class UnifiedClaudeScheduler {
`✅ Added Claude Console account to available pool: ${account.name} (priority: ${account.priority})`
)
} else {
- logger.warn(`⚠️ Claude Console account ${account.name} is rate limited`)
+ if (isRateLimited) {
+ logger.warn(`⚠️ Claude Console account ${account.name} is rate limited`)
+ }
+ if (isQuotaExceeded) {
+ logger.warn(`💰 Claude Console account ${account.name} quota exceeded`)
+ }
}
} else {
logger.info(
@@ -475,10 +497,17 @@ class UnifiedClaudeScheduler {
logger.info(`🚫 Claude Console account ${accountId} is not schedulable`)
return false
}
- // 检查是否被限流
+ // 主动触发一次额度检查
+ try {
+ await claudeConsoleAccountService.checkQuotaUsage(accountId)
+ } catch (e) {}
+ // 检查是否被限流或额度超限
if (await claudeConsoleAccountService.isAccountRateLimited(accountId)) {
return false
}
+ if (await claudeConsoleAccountService.isAccountQuotaExceeded(accountId)) {
+ return false
+ }
// 检查是否未授权(401错误)
if (account.status === 'unauthorized') {
return false
diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue
index 19182c8b..0d4c3226 100644
--- a/web/admin-spa/src/components/accounts/AccountForm.vue
+++ b/web/admin-spa/src/components/accounts/AccountForm.vue
@@ -658,6 +658,41 @@
+
+
+
+
+
+
+ 设置每日使用额度,0 表示不限制
+
+
+
+
+
+
+
+ 每日自动重置额度的时间
+
+
+
+
留空表示不更新 API Key
+
+
+
+
+
+
+ 设置每日使用额度,0 表示不限制
+
+
+
+
+
+
+ 每日自动重置额度的时间
+
+
+
+
+
+
+
+ 今日使用情况
+
+
+ ${{ calculateCurrentUsage().toFixed(4) }} / ${{ form.dailyQuota.toFixed(2) }}
+
+
+
+
+
+ 剩余: ${{ Math.max(0, form.dailyQuota - calculateCurrentUsage()).toFixed(2) }}
+
+
+ {{ usagePercentage.toFixed(1) }}% 已使用
+
+
+
+
0 : true,
rateLimitDuration: props.account?.rateLimitDuration || 60,
+ // 额度管理字段
+ dailyQuota: props.account?.dailyQuota || 0,
+ dailyUsage: props.account?.dailyUsage || 0,
+ quotaResetTime: props.account?.quotaResetTime || '00:00',
// Bedrock 特定字段
accessKeyId: props.account?.accessKeyId || '',
secretAccessKey: props.account?.secretAccessKey || '',
@@ -2162,6 +2270,45 @@ const canExchangeSetupToken = computed(() => {
return setupTokenAuthUrl.value && setupTokenAuthCode.value.trim()
})
+// 获取当前使用量(实时)
+const calculateCurrentUsage = () => {
+ // 如果不是编辑模式或没有账户ID,返回0
+ if (!isEdit.value || !props.account?.id) {
+ return 0
+ }
+
+ // 如果已经加载了今日使用数据,直接使用
+ if (typeof form.value.dailyUsage === 'number') {
+ return form.value.dailyUsage
+ }
+
+ return 0
+}
+
+// 计算额度使用百分比
+const usagePercentage = computed(() => {
+ if (!form.value.dailyQuota || form.value.dailyQuota <= 0) {
+ return 0
+ }
+ const currentUsage = calculateCurrentUsage()
+ return (currentUsage / form.value.dailyQuota) * 100
+})
+
+// 加载账户今日使用情况
+const loadAccountUsage = async () => {
+ if (!isEdit.value || !props.account?.id) return
+
+ try {
+ const response = await apiClient.get(`/admin/claude-console-accounts/${props.account.id}/usage`)
+ if (response) {
+ // 更新表单中的使用量数据
+ form.value.dailyUsage = response.dailyUsage || 0
+ }
+ } catch (error) {
+ console.warn('Failed to load account usage:', error)
+ }
+}
+
// // 计算是否可以创建
// const canCreate = computed(() => {
// if (form.value.addType === 'manual') {
@@ -2601,6 +2748,9 @@ const createAccount = async () => {
data.userAgent = form.value.userAgent || null
// 如果不启用限流,传递 0 表示不限流
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
+ // 额度管理字段
+ data.dailyQuota = form.value.dailyQuota || 0
+ data.quotaResetTime = form.value.quotaResetTime || '00:00'
} else if (form.value.platform === 'bedrock') {
// Bedrock 账户特定数据 - 构造 awsCredentials 对象
data.awsCredentials = {
@@ -2798,6 +2948,9 @@ const updateAccount = async () => {
data.userAgent = form.value.userAgent || null
// 如果不启用限流,传递 0 表示不限流
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
+ // 额度管理字段
+ data.dailyQuota = form.value.dailyQuota || 0
+ data.quotaResetTime = form.value.quotaResetTime || '00:00'
}
// Bedrock 特定更新
@@ -3207,7 +3360,16 @@ watch(
// Azure OpenAI 特定字段
azureEndpoint: newAccount.azureEndpoint || '',
apiVersion: newAccount.apiVersion || '',
- deploymentName: newAccount.deploymentName || ''
+ deploymentName: newAccount.deploymentName || '',
+ // 额度管理字段
+ dailyQuota: newAccount.dailyQuota || 0,
+ dailyUsage: newAccount.dailyUsage || 0,
+ quotaResetTime: newAccount.quotaResetTime || '00:00'
+ }
+
+ // 如果是Claude Console账户,加载实时使用情况
+ if (newAccount.platform === 'claude-console') {
+ loadAccountUsage()
}
// 如果是分组类型,加载分组ID
@@ -3287,6 +3449,10 @@ const clearUnifiedCache = async () => {
onMounted(() => {
// 获取Claude Code统一User-Agent信息
fetchUnifiedUserAgent()
+ // 如果是编辑模式且是Claude Console账户,加载使用情况
+ if (isEdit.value && props.account?.platform === 'claude-console') {
+ loadAccountUsage()
+ }
})
// 监听平台变化,当切换到Claude平台时获取统一User-Agent信息
diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue
index 01fae1c5..9c2ccb7e 100644
--- a/web/admin-spa/src/views/AccountsView.vue
+++ b/web/admin-spa/src/views/AccountsView.vue
@@ -584,6 +584,44 @@
+
+
+
+
+ 额度进度
+
+ {{ getQuotaUsagePercent(account).toFixed(1) }}%
+
+
+
+
+
+ ${{ formatCost(account.usage?.daily?.cost || 0) }} / ${{
+ Number(account.dailyQuota).toFixed(2)
+ }}
+
+
+
+ 剩余 ${{ formatRemainingQuota(account) }}
+ 重置 {{ account.quotaResetTime || '00:00' }}
+
+
+
+
+
+
@@ -1788,6 +1826,29 @@ const formatCost = (cost) => {
return cost.toFixed(2)
}
+// 额度使用百分比(Claude Console)
+const getQuotaUsagePercent = (account) => {
+ const used = Number(account?.usage?.daily?.cost || 0)
+ const quota = Number(account?.dailyQuota || 0)
+ if (!quota || quota <= 0) return 0
+ return (used / quota) * 100
+}
+
+// 额度进度条颜色(Claude Console)
+const getQuotaBarClass = (percent) => {
+ if (percent >= 90) return 'bg-red-500'
+ if (percent >= 70) return 'bg-yellow-500'
+ return 'bg-green-500'
+}
+
+// 剩余额度(Claude Console)
+const formatRemainingQuota = (account) => {
+ const used = Number(account?.usage?.daily?.cost || 0)
+ const quota = Number(account?.dailyQuota || 0)
+ if (!quota || quota <= 0) return '0.00'
+ return Math.max(0, quota - used).toFixed(2)
+}
+
// 计算每日费用(使用后端返回的精确费用数据)
const calculateDailyCost = (account) => {
if (!account.usage || !account.usage.daily) return '0.0000'
From 19cf38d92da5f1f988d50c44d8acb52e62f9a2eb Mon Sep 17 00:00:00 2001
From: sususu
Date: Fri, 5 Sep 2025 17:01:40 +0800
Subject: [PATCH 08/15] fix(unifiedClaudeScheduler): Add error logging for
quota check failures.
---
src/services/unifiedClaudeScheduler.js | 19 ++++++++++++++++---
1 file changed, 16 insertions(+), 3 deletions(-)
diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js
index 50703c6b..c373d1f5 100644
--- a/src/services/unifiedClaudeScheduler.js
+++ b/src/services/unifiedClaudeScheduler.js
@@ -284,7 +284,12 @@ class UnifiedClaudeScheduler {
// 主动触发一次额度检查
try {
await claudeConsoleAccountService.checkQuotaUsage(boundConsoleAccount.id)
- } catch (e) {}
+ } catch (e) {
+ logger.warn(
+ `Failed to check quota for bound Claude Console account ${boundConsoleAccount.name}: ${e.message}`
+ )
+ // 继续使用该账号
+ }
// 检查限流状态和额度状态
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(
@@ -396,7 +401,12 @@ class UnifiedClaudeScheduler {
// 主动触发一次额度检查,确保状态即时生效
try {
await claudeConsoleAccountService.checkQuotaUsage(account.id)
- } catch (e) {}
+ } catch (e) {
+ logger.warn(
+ `Failed to check quota for Claude Console account ${account.name}: ${e.message}`
+ )
+ // 继续处理该账号
+ }
// 检查是否被限流
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(account.id)
@@ -549,7 +559,10 @@ class UnifiedClaudeScheduler {
// 检查是否超额
try {
await claudeConsoleAccountService.checkQuotaUsage(accountId)
- } catch (e) {}
+ } catch (e) {
+ logger.warn(`Failed to check quota for Claude Console account ${accountId}: ${e.message}`)
+ // 继续处理
+ }
// 检查是否被限流
if (await claudeConsoleAccountService.isAccountRateLimited(accountId)) {
From 503f20b06bece86484f2da55419421396c51a657 Mon Sep 17 00:00:00 2001
From: maplegao
Date: Fri, 5 Sep 2025 16:40:24 +0800
Subject: [PATCH 09/15] =?UTF-8?q?webhook=E6=97=B6=E9=97=B4=E5=8F=AF?=
=?UTF-8?q?=E4=BB=A5=E6=8C=87=E5=AE=9A=E6=97=B6=E5=8C=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/services/webhookService.js | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/src/services/webhookService.js b/src/services/webhookService.js
index c026ead6..5cd575f8 100644
--- a/src/services/webhookService.js
+++ b/src/services/webhookService.js
@@ -3,6 +3,7 @@ const crypto = require('crypto')
const logger = require('../utils/logger')
const webhookConfigService = require('./webhookConfigService')
const { getISOStringWithTimezone } = require('../utils/dateHelper')
+const config = require('../../config/config')
class WebhookService {
constructor() {
@@ -15,6 +16,7 @@ class WebhookService {
custom: this.sendToCustom.bind(this),
bark: this.sendToBark.bind(this)
}
+ this.timezone = config.system.timezone || 'Asia/Shanghai'
}
/**
@@ -309,11 +311,10 @@ class WebhookService {
formatMessageForWechatWork(type, data) {
const title = this.getNotificationTitle(type)
const details = this.formatNotificationDetails(data)
-
return (
`## ${title}\n\n` +
`> **服务**: Claude Relay Service\n` +
- `> **时间**: ${new Date().toLocaleString('zh-CN')}\n\n${details}`
+ `> **时间**: ${new Date().toLocaleString('zh-CN', {timeZone: this.timezone})}\n\n${details}`
)
}
@@ -325,7 +326,7 @@ class WebhookService {
return (
`#### 服务: Claude Relay Service\n` +
- `#### 时间: ${new Date().toLocaleString('zh-CN')}\n\n${details}`
+ `#### 时间: ${new Date().toLocaleString('zh-CN', {timeZone: this.timezone})}\n\n${details}`
)
}
@@ -450,7 +451,7 @@ class WebhookService {
// 添加服务标识和时间戳
lines.push(`\n服务: Claude Relay Service`)
- lines.push(`时间: ${new Date().toLocaleString('zh-CN')}`)
+ lines.push(`时间: ${new Date().toLocaleString('zh-CN', {timeZone: this.timezone})}`)
return lines.join('\n')
}
From d4989f54011770cf60ee1b2010b646c9c9a86ed8 Mon Sep 17 00:00:00 2001
From: maplegao
Date: Fri, 5 Sep 2025 20:51:07 +0800
Subject: [PATCH 10/15] format
---
src/services/webhookService.js | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/services/webhookService.js b/src/services/webhookService.js
index 5cd575f8..42a059a5 100644
--- a/src/services/webhookService.js
+++ b/src/services/webhookService.js
@@ -314,7 +314,7 @@ class WebhookService {
return (
`## ${title}\n\n` +
`> **服务**: Claude Relay Service\n` +
- `> **时间**: ${new Date().toLocaleString('zh-CN', {timeZone: this.timezone})}\n\n${details}`
+ `> **时间**: ${new Date().toLocaleString('zh-CN', { timeZone: this.timezone })}\n\n${details}`
)
}
@@ -326,7 +326,7 @@ class WebhookService {
return (
`#### 服务: Claude Relay Service\n` +
- `#### 时间: ${new Date().toLocaleString('zh-CN', {timeZone: this.timezone})}\n\n${details}`
+ `#### 时间: ${new Date().toLocaleString('zh-CN', { timeZone: this.timezone })}\n\n${details}`
)
}
@@ -451,7 +451,7 @@ class WebhookService {
// 添加服务标识和时间戳
lines.push(`\n服务: Claude Relay Service`)
- lines.push(`时间: ${new Date().toLocaleString('zh-CN', {timeZone: this.timezone})}`)
+ lines.push(`时间: ${new Date().toLocaleString('zh-CN', { timeZone: this.timezone })}`)
return lines.join('\n')
}
From 96e505d662f3109088bba51c1f2043adf04035ba Mon Sep 17 00:00:00 2001
From: maplegao
Date: Fri, 5 Sep 2025 21:42:49 +0800
Subject: [PATCH 11/15] eslint fix
---
src/services/webhookService.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
mode change 100644 => 100755 src/services/webhookService.js
diff --git a/src/services/webhookService.js b/src/services/webhookService.js
old mode 100644
new mode 100755
index 42a059a5..d791ce68
--- a/src/services/webhookService.js
+++ b/src/services/webhookService.js
@@ -3,7 +3,7 @@ const crypto = require('crypto')
const logger = require('../utils/logger')
const webhookConfigService = require('./webhookConfigService')
const { getISOStringWithTimezone } = require('../utils/dateHelper')
-const config = require('../../config/config')
+const appConfig = require('../../config/config')
class WebhookService {
constructor() {
@@ -16,7 +16,7 @@ class WebhookService {
custom: this.sendToCustom.bind(this),
bark: this.sendToBark.bind(this)
}
- this.timezone = config.system.timezone || 'Asia/Shanghai'
+ this.timezone = appConfig.system.timezone || 'Asia/Shanghai'
}
/**
From 56c48a4304b525334457861d36ea4ebe6a2bdce8 Mon Sep 17 00:00:00 2001
From: maplegao
Date: Sat, 6 Sep 2025 14:22:33 +0800
Subject: [PATCH 12/15] =?UTF-8?q?=E6=97=A5=E5=BF=97=E6=A0=BC=E5=BC=8F?=
=?UTF-8?q?=E9=80=82=E9=85=8D=E5=B8=82=E5=8C=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/utils/logger.js | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/utils/logger.js b/src/utils/logger.js
index ac4cd618..30093f05 100644
--- a/src/utils/logger.js
+++ b/src/utils/logger.js
@@ -1,6 +1,7 @@
const winston = require('winston')
const DailyRotateFile = require('winston-daily-rotate-file')
const config = require('../../config/config')
+const { formatDateWithTimezone } = require('../utils/dateHelper')
const path = require('path')
const fs = require('fs')
const os = require('os')
@@ -95,7 +96,7 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
// 📝 增强的日志格式
const createLogFormat = (colorize = false) => {
const formats = [
- winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
+ winston.format.timestamp({ format: () => formatDateWithTimezone(new Date(), false) }),
winston.format.errors({ stack: true })
// 移除 winston.format.metadata() 来避免自动包装
]
From a71f0e58a281b6ed850f079d4cc1c5fc18d56a32 Mon Sep 17 00:00:00 2001
From: maplegao
Date: Sat, 6 Sep 2025 14:27:18 +0800
Subject: [PATCH 13/15] =?UTF-8?q?=E4=BF=AE=E5=A4=8DREADME.md=E4=B8=AD=20?=
=?UTF-8?q?=E7=89=88=E6=9C=AC=E6=9B=B4=E6=96=B0=E4=B8=8Dwork=E9=97=AE?=
=?UTF-8?q?=E9=A2=98=EF=BC=8C=20=E9=87=8D=E5=90=AF=E8=84=9A=E6=9C=AC?=
=?UTF-8?q?=E4=B8=AD=E6=B2=A1=E6=9C=89stop?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
scripts/manage.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/scripts/manage.js b/scripts/manage.js
index 6e3f7937..df8a14f0 100644
--- a/scripts/manage.js
+++ b/scripts/manage.js
@@ -185,7 +185,7 @@ class ServiceManager {
restart(daemon = false) {
console.log('🔄 重启服务...')
-
+ this.stop()
// 等待停止完成
setTimeout(() => {
this.start(daemon)
From d2f3f6866c944d3b75a29cccdb39d4e9605e712f Mon Sep 17 00:00:00 2001
From: shaw
Date: Sat, 6 Sep 2025 17:39:05 +0800
Subject: [PATCH 14/15] =?UTF-8?q?feat:=20Codex=E8=B4=A6=E5=8F=B7=E7=AE=A1?=
=?UTF-8?q?=E7=90=86=E4=BC=98=E5=8C=96=E4=B8=8EAPI=20Key=E6=BF=80=E6=B4=BB?=
=?UTF-8?q?=E6=9C=BA=E5=88=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
✨ 新功能
- 支持通过refreshToken新增Codex账号,创建时立即验证token有效性
- API Key新增首次使用自动激活机制,支持activation模式设置有效期
- 前端账号表单增加token验证功能,确保账号创建成功
🐛 修复
- 修复Codex token刷新失败问题,增加分布式锁防止并发刷新
- 优化token刷新错误处理,提供更详细的错误信息和建议
- 修复OpenAI账号token过期检测和自动刷新逻辑
📝 文档更新
- 更新README中Codex使用说明,改为config.toml配置方式
- 优化Cherry Studio等第三方工具接入文档
- 添加详细的配置示例和账号类型说明
🎨 界面优化
- 改进账号创建表单UI,支持手动和OAuth两种模式
- 优化API Key过期时间编辑弹窗,支持激活操作
- 调整教程页面布局,提升移动端响应式体验
💡 代码改进
- 重构token刷新服务,增强错误处理和重试机制
- 优化代理配置处理,确保OAuth请求正确使用代理
- 改进webhook通知,增加token刷新失败告警
---
README.md | 95 ++++--
src/routes/admin.js | 319 +++++++++++++++++-
src/routes/openaiRoutes.js | 27 +-
src/services/apiKeyService.js | 52 ++-
src/services/openaiAccountService.js | 294 +++++++++++++---
src/services/unifiedOpenAIScheduler.js | 28 +-
src/utils/webhookNotifier.js | 6 +
web/admin-spa/src/assets/styles/global.css | 2 +-
.../src/components/accounts/AccountForm.vue | 281 +++++++++------
.../src/components/accounts/ProxyConfig.vue | 10 +-
.../apikeys/BatchEditApiKeyModal.vue | 22 +-
.../components/apikeys/CreateApiKeyModal.vue | 145 ++++++--
.../components/apikeys/EditApiKeyModal.vue | 20 +-
.../components/apikeys/ExpiryEditModal.vue | 62 +++-
.../src/components/common/AccountSelector.vue | 4 +-
web/admin-spa/src/config/api.js | 11 +-
web/admin-spa/src/utils/toast.js | 5 +-
web/admin-spa/src/views/ApiKeysView.vue | 33 +-
web/admin-spa/src/views/TutorialView.vue | 278 +++++----------
19 files changed, 1231 insertions(+), 463 deletions(-)
diff --git a/README.md b/README.md
index 25b026dc..30dcfb50 100644
--- a/README.md
+++ b/README.md
@@ -474,50 +474,101 @@ claude
gemini # 或其他 Gemini CLI 命令
```
-**Codex 设置环境变量:**
+**Codex 配置:**
-```bash
-export OPENAI_BASE_URL="http://127.0.0.1:3000/openai" # 根据实际填写你服务器的ip地址或者域名
-export OPENAI_API_KEY="后台创建的API密钥" # 使用后台创建的API密钥
-```
-
-**Codex 额外配置:**
-
-需要在 `~/.codex/config.toml` 文件中添加以下配置来禁用响应存储:
+在 `~/.codex/config.toml` 文件中添加以下配置:
```toml
+model_provider = "crs"
+model = "gpt-5"
+model_reasoning_effort = "high"
disable_response_storage = true
+
+[model_providers.crs]
+name = "crs"
+base_url = "http://127.0.0.1:3000/openai" # 根据实际填写你服务器的ip地址或者域名
+wire_api = "responses"
+```
+
+在 `~/.codex/auth.json` 文件中配置API密钥:
+
+```json
+{
+ "OPENAI_API_KEY": "你的后台创建的API密钥"
+}
```
### 5. 第三方工具API接入
-本服务支持多种API端点格式,方便接入不同的第三方工具(如Cherry Studio等):
+本服务支持多种API端点格式,方便接入不同的第三方工具(如Cherry Studio等)。
-**Claude标准格式:**
+#### Cherry Studio 接入示例
+
+Cherry Studio支持多种AI服务的接入,下面是不同账号类型的详细配置:
+
+**1. Claude账号接入:**
```
-# 如果工具支持Claude标准格式,请使用该接口
+# API地址
http://你的服务器:3000/claude/
+
+# 模型ID示例
+claude-sonnet-4-20250514 # Claude Sonnet 4
+claude-opus-4-20250514 # Claude Opus 4
```
-**OpenAI兼容格式:**
+配置步骤:
+- 供应商类型选择"Anthropic"
+- API地址填入:`http://你的服务器:3000/claude/`
+- API Key填入:后台创建的API密钥(cr_开头)
+
+**2. Gemini账号接入:**
```
-# 适用于需要OpenAI格式的第三方工具
-http://你的服务器:3000/openai/claude/v1/
+# API地址
+http://你的服务器:3000/gemini/
+
+# 模型ID示例
+gemini-2.5-pro # Gemini 2.5 Pro
```
-**接入示例:**
+配置步骤:
+- 供应商类型选择"Gemini"
+- API地址填入:`http://你的服务器:3000/gemini/`
+- API Key填入:后台创建的API密钥(cr_开头)
-- **Cherry Studio**: 使用OpenAI格式 `http://你的服务器:3000/openai/claude/v1/` 使用Codex cli API `http://你的服务器:3000/openai/responses`
-- **其他支持自定义API的工具**: 根据工具要求选择合适的格式
+**3. Codex接入:**
+
+```
+# API地址
+http://你的服务器:3000/openai/
+
+# 模型ID(固定)
+gpt-5 # Codex使用固定模型ID
+```
+
+配置步骤:
+- 供应商类型选择"Openai-Response"
+- API地址填入:`http://你的服务器:3000/openai/`
+- API Key填入:后台创建的API密钥(cr_开头)
+- **重要**:Codex只支持Openai-Response标准
+
+#### 其他第三方工具接入
+
+**接入要点:**
+
+- 所有账号类型都使用相同的API密钥(在后台统一创建)
+- 根据不同的路由前缀自动识别账号类型
+- `/claude/` - 使用Claude账号池
+- `/gemini/` - 使用Gemini账号池
+- `/openai/` - 使用Codex账号(只支持Openai-Response格式)
+- 支持所有标准API端点(messages、models等)
**重要说明:**
-- 所有格式都支持相同的功能,仅是路径不同
-- `/api/v1/messages` = `/claude/v1/messages` = `/openai/claude/v1/messages`
-- 选择适合你使用工具的格式即可
-- 支持所有Claude API端点(messages、models等)
+- 确保在后台已添加对应类型的账号(Claude/Gemini/Codex)
+- API密钥可以通用,系统会根据路由自动选择账号类型
+- 建议为不同用户创建不同的API密钥便于使用统计
---
diff --git a/src/routes/admin.js b/src/routes/admin.js
index 40d35aee..c26e613c 100644
--- a/src/routes/admin.js
+++ b/src/routes/admin.js
@@ -491,7 +491,9 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
allowedClients,
dailyCostLimit,
weeklyOpusCostLimit,
- tags
+ tags,
+ activationDays, // 新增:激活后有效天数
+ expirationMode // 新增:过期模式
} = req.body
// 输入验证
@@ -569,6 +571,31 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
return res.status(400).json({ error: 'All tags must be non-empty strings' })
}
+ // 验证激活相关字段
+ if (expirationMode && !['fixed', 'activation'].includes(expirationMode)) {
+ return res
+ .status(400)
+ .json({ error: 'Expiration mode must be either "fixed" or "activation"' })
+ }
+
+ if (expirationMode === 'activation') {
+ if (
+ !activationDays ||
+ !Number.isInteger(Number(activationDays)) ||
+ Number(activationDays) < 1
+ ) {
+ return res
+ .status(400)
+ .json({ error: 'Activation days must be a positive integer when using activation mode' })
+ }
+ // 激活模式下不应该设置固定过期时间
+ if (expiresAt) {
+ return res
+ .status(400)
+ .json({ error: 'Cannot set fixed expiration date when using activation mode' })
+ }
+ }
+
const newKey = await apiKeyService.generateApiKey({
name,
description,
@@ -590,7 +617,9 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
allowedClients,
dailyCostLimit,
weeklyOpusCostLimit,
- tags
+ tags,
+ activationDays,
+ expirationMode
})
logger.success(`🔑 Admin created new API key: ${name}`)
@@ -624,7 +653,9 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
allowedClients,
dailyCostLimit,
weeklyOpusCostLimit,
- tags
+ tags,
+ activationDays,
+ expirationMode
} = req.body
// 输入验证
@@ -668,7 +699,9 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
allowedClients,
dailyCostLimit,
weeklyOpusCostLimit,
- tags
+ tags,
+ activationDays,
+ expirationMode
})
// 保留原始 API Key 供返回
@@ -1142,6 +1175,85 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
}
})
+// 修改API Key过期时间(包括手动激活功能)
+router.patch('/api-keys/:keyId/expiration', authenticateAdmin, async (req, res) => {
+ try {
+ const { keyId } = req.params
+ const { expiresAt, activateNow } = req.body
+
+ // 获取当前API Key信息
+ const keyData = await redis.getApiKey(keyId)
+ if (!keyData || Object.keys(keyData).length === 0) {
+ return res.status(404).json({ error: 'API key not found' })
+ }
+
+ const updates = {}
+
+ // 如果是激活操作(用于未激活的key)
+ if (activateNow === true) {
+ if (keyData.expirationMode === 'activation' && keyData.isActivated !== 'true') {
+ const now = new Date()
+ const activationDays = parseInt(keyData.activationDays || 30)
+ const newExpiresAt = new Date(now.getTime() + activationDays * 24 * 60 * 60 * 1000)
+
+ updates.isActivated = 'true'
+ updates.activatedAt = now.toISOString()
+ updates.expiresAt = newExpiresAt.toISOString()
+
+ logger.success(
+ `🔓 API key manually activated by admin: ${keyId} (${keyData.name}), expires at ${newExpiresAt.toISOString()}`
+ )
+ } else {
+ return res.status(400).json({
+ error: 'Cannot activate',
+ message: 'Key is either already activated or not in activation mode'
+ })
+ }
+ }
+
+ // 如果提供了新的过期时间(但不是激活操作)
+ if (expiresAt !== undefined && activateNow !== true) {
+ // 验证过期时间格式
+ if (expiresAt && isNaN(Date.parse(expiresAt))) {
+ return res.status(400).json({ error: 'Invalid expiration date format' })
+ }
+
+ // 如果设置了过期时间,确保key是激活状态
+ if (expiresAt) {
+ updates.expiresAt = new Date(expiresAt).toISOString()
+ // 如果之前是未激活状态,现在激活它
+ if (keyData.isActivated !== 'true') {
+ updates.isActivated = 'true'
+ updates.activatedAt = new Date().toISOString()
+ }
+ } else {
+ // 清除过期时间(永不过期)
+ updates.expiresAt = ''
+ }
+ }
+
+ if (Object.keys(updates).length === 0) {
+ return res.status(400).json({ error: 'No valid updates provided' })
+ }
+
+ // 更新API Key
+ await apiKeyService.updateApiKey(keyId, updates)
+
+ logger.success(`📝 Updated API key expiration: ${keyId} (${keyData.name})`)
+ return res.json({
+ success: true,
+ message: 'API key expiration updated successfully',
+ updates
+ })
+ } catch (error) {
+ logger.error('❌ Failed to update API key expiration:', error)
+ return res.status(500).json({
+ error: 'Failed to update API key expiration',
+ message: error.message
+ })
+ }
+})
+
// 批量删除API Keys(必须在 :keyId 路由之前定义)
router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => {
try {
@@ -5633,7 +5745,9 @@ router.post('/openai-accounts', authenticateAdmin, async (req, res) => {
accountType,
groupId,
rateLimitDuration,
- priority
+ priority,
+ needsImmediateRefresh, // 是否需要立即刷新
+ requireRefreshSuccess // 是否必须刷新成功才能创建
} = req.body
if (!name) {
@@ -5642,7 +5756,8 @@ router.post('/openai-accounts', authenticateAdmin, async (req, res) => {
message: '账户名称不能为空'
})
}
- // 创建账户数据
+
+ // 准备账户数据
const accountData = {
name,
description: description || '',
@@ -5657,7 +5772,83 @@ router.post('/openai-accounts', authenticateAdmin, async (req, res) => {
schedulable: true
}
- // 创建账户
+ // 如果需要立即刷新且必须成功(OpenAI 手动模式)
+ if (needsImmediateRefresh && requireRefreshSuccess) {
+ // 先创建临时账户以测试刷新
+ const tempAccount = await openaiAccountService.createAccount(accountData)
+
+ try {
+ logger.info(`🔄 测试刷新 OpenAI 账户以获取完整 token 信息`)
+
+ // 尝试刷新 token(会自动使用账户配置的代理)
+ await openaiAccountService.refreshAccountToken(tempAccount.id)
+
+ // 刷新成功,获取更新后的账户信息
+ const refreshedAccount = await openaiAccountService.getAccount(tempAccount.id)
+
+ // 检查是否获取到了 ID Token
+ if (!refreshedAccount.idToken || refreshedAccount.idToken === '') {
+ // 没有获取到 ID Token,删除账户
+ await openaiAccountService.deleteAccount(tempAccount.id)
+ throw new Error('无法获取 ID Token,请检查 Refresh Token 是否有效')
+ }
+
+ // 如果是分组类型,添加到分组
+ if (accountType === 'group' && groupId) {
+ await accountGroupService.addAccountToGroup(tempAccount.id, groupId, 'openai')
+ }
+
+ // 清除敏感信息后返回
+ delete refreshedAccount.idToken
+ delete refreshedAccount.accessToken
+ delete refreshedAccount.refreshToken
+
+ logger.success(`✅ 创建并验证 OpenAI 账户成功: ${name} (ID: ${tempAccount.id})`)
+
+ return res.json({
+ success: true,
+ data: refreshedAccount,
+ message: '账户创建成功,并已获取完整 token 信息'
+ })
+ } catch (refreshError) {
+ // 刷新失败,删除临时创建的账户
+ logger.warn(`❌ 刷新失败,删除临时账户: ${refreshError.message}`)
+ await openaiAccountService.deleteAccount(tempAccount.id)
+
+ // 构建详细的错误信息
+ const errorResponse = {
+ success: false,
+ message: '账户创建失败',
+ error: refreshError.message
+ }
+
+ // 添加更详细的错误信息
+ if (refreshError.status) {
+ errorResponse.errorCode = refreshError.status
+ }
+ if (refreshError.details) {
+ errorResponse.errorDetails = refreshError.details
+ }
+ if (refreshError.code) {
+ errorResponse.networkError = refreshError.code
+ }
+
+ // 提供更友好的错误提示
+ if (refreshError.message.includes('Refresh Token 无效')) {
+ errorResponse.suggestion = '请检查 Refresh Token 是否正确,或重新通过 OAuth 授权获取'
+ } else if (refreshError.message.includes('代理')) {
+ errorResponse.suggestion = '请检查代理配置是否正确,包括地址、端口和认证信息'
+ } else if (refreshError.message.includes('过于频繁')) {
+ errorResponse.suggestion = '请稍后再试,或更换代理 IP'
+ } else if (refreshError.message.includes('连接')) {
+ errorResponse.suggestion = '请检查网络连接和代理设置'
+ }
+
+ return res.status(400).json(errorResponse)
+ }
+ }
+
+ // 不需要强制刷新的情况(OAuth 模式或其他平台)
const createdAccount = await openaiAccountService.createAccount(accountData)
// 如果是分组类型,添加到分组
@@ -5665,6 +5856,17 @@ router.post('/openai-accounts', authenticateAdmin, async (req, res) => {
await accountGroupService.addAccountToGroup(createdAccount.id, groupId, 'openai')
}
+ // 如果需要刷新但不强制成功(OAuth 模式可能已有完整信息)
+ if (needsImmediateRefresh && !requireRefreshSuccess) {
+ try {
+ logger.info(`🔄 尝试刷新 OpenAI 账户 ${createdAccount.id}`)
+ await openaiAccountService.refreshAccountToken(createdAccount.id)
+ logger.info(`✅ 刷新成功`)
+ } catch (refreshError) {
+ logger.warn(`⚠️ 刷新失败,但账户已创建: ${refreshError.message}`)
+ }
+ }
+
logger.success(`✅ 创建 OpenAI 账户成功: ${name} (ID: ${createdAccount.id})`)
return res.json({
@@ -5686,6 +5888,7 @@ router.put('/openai-accounts/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const updates = req.body
+ const { needsImmediateRefresh, requireRefreshSuccess } = updates
// 验证accountType的有效性
if (updates.accountType && !['shared', 'dedicated', 'group'].includes(updates.accountType)) {
@@ -5705,6 +5908,93 @@ router.put('/openai-accounts/:id', authenticateAdmin, async (req, res) => {
return res.status(404).json({ error: 'Account not found' })
}
+ // 如果更新了 Refresh Token,需要验证其有效性
+ if (updates.openaiOauth?.refreshToken && needsImmediateRefresh && requireRefreshSuccess) {
+ // 先更新 token 信息
+ const tempUpdateData = {}
+ if (updates.openaiOauth.refreshToken) {
+ tempUpdateData.refreshToken = updates.openaiOauth.refreshToken
+ }
+ if (updates.openaiOauth.accessToken) {
+ tempUpdateData.accessToken = updates.openaiOauth.accessToken
+ }
+ // 更新代理配置(如果有)
+ if (updates.proxy !== undefined) {
+ tempUpdateData.proxy = updates.proxy
+ }
+
+ // 临时更新账户以测试新的 token
+ await openaiAccountService.updateAccount(id, tempUpdateData)
+
+ try {
+ logger.info(`🔄 验证更新的 OpenAI token (账户: ${id})`)
+
+ // 尝试刷新 token(会使用账户配置的代理)
+ await openaiAccountService.refreshAccountToken(id)
+
+ // 获取刷新后的账户信息
+ const refreshedAccount = await openaiAccountService.getAccount(id)
+
+ // 检查是否获取到了 ID Token
+ if (!refreshedAccount.idToken || refreshedAccount.idToken === '') {
+ // 恢复原始 token
+ await openaiAccountService.updateAccount(id, {
+ refreshToken: currentAccount.refreshToken,
+ accessToken: currentAccount.accessToken,
+ idToken: currentAccount.idToken
+ })
+
+ return res.status(400).json({
+ success: false,
+ message: '无法获取 ID Token,请检查 Refresh Token 是否有效',
+ error: 'Invalid refresh token'
+ })
+ }
+
+ logger.success(`✅ Token 验证成功,继续更新账户信息`)
+ } catch (refreshError) {
+ // 刷新失败,恢复原始 token
+ logger.warn(`❌ Token 验证失败,恢复原始配置: ${refreshError.message}`)
+ await openaiAccountService.updateAccount(id, {
+ refreshToken: currentAccount.refreshToken,
+ accessToken: currentAccount.accessToken,
+ idToken: currentAccount.idToken,
+ proxy: currentAccount.proxy
+ })
+
+ // 构建详细的错误信息
+ const errorResponse = {
+ success: false,
+ message: '更新失败',
+ error: refreshError.message
+ }
+
+ // 添加更详细的错误信息
+ if (refreshError.status) {
+ errorResponse.errorCode = refreshError.status
+ }
+ if (refreshError.details) {
+ errorResponse.errorDetails = refreshError.details
+ }
+ if (refreshError.code) {
+ errorResponse.networkError = refreshError.code
+ }
+
+ // 提供更友好的错误提示
+ if (refreshError.message.includes('Refresh Token 无效')) {
+ errorResponse.suggestion = '请检查 Refresh Token 是否正确,或重新通过 OAuth 授权获取'
+ } else if (refreshError.message.includes('代理')) {
+ errorResponse.suggestion = '请检查代理配置是否正确,包括地址、端口和认证信息'
+ } else if (refreshError.message.includes('过于频繁')) {
+ errorResponse.suggestion = '请稍后再试,或更换代理 IP'
+ } else if (refreshError.message.includes('连接')) {
+ errorResponse.suggestion = '请检查网络连接和代理设置'
+ }
+
+ return res.status(400).json(errorResponse)
+ }
+ }
+
// 处理分组的变更
if (updates.accountType !== undefined) {
// 如果之前是分组类型,需要从原分组中移除
@@ -5726,9 +6016,7 @@ router.put('/openai-accounts/:id', authenticateAdmin, async (req, res) => {
// 处理敏感数据加密
if (updates.openaiOauth) {
updateData.openaiOauth = updates.openaiOauth
- if (updates.openaiOauth.idToken) {
- updateData.idToken = updates.openaiOauth.idToken
- }
+ // 编辑时不允许直接输入 ID Token,只能通过刷新获取
if (updates.openaiOauth.accessToken) {
updateData.accessToken = updates.openaiOauth.accessToken
}
@@ -5762,6 +6050,17 @@ router.put('/openai-accounts/:id', authenticateAdmin, async (req, res) => {
const updatedAccount = await openaiAccountService.updateAccount(id, updateData)
+ // 如果需要刷新但不强制成功(非关键更新)
+ if (needsImmediateRefresh && !requireRefreshSuccess) {
+ try {
+ logger.info(`🔄 尝试刷新 OpenAI 账户 ${id}`)
+ await openaiAccountService.refreshAccountToken(id)
+ logger.info(`✅ 刷新成功`)
+ } catch (refreshError) {
+ logger.warn(`⚠️ 刷新失败,但账户信息已更新: ${refreshError.message}`)
+ }
+ }
+
logger.success(`📝 Admin updated OpenAI account: ${id}`)
return res.json({ success: true, data: updatedAccount })
} catch (error) {
diff --git a/src/routes/openaiRoutes.js b/src/routes/openaiRoutes.js
index 9efb2981..283ab896 100644
--- a/src/routes/openaiRoutes.js
+++ b/src/routes/openaiRoutes.js
@@ -3,7 +3,6 @@ const axios = require('axios')
const router = express.Router()
const logger = require('../utils/logger')
const { authenticateApiKey } = require('../middleware/auth')
-const claudeAccountService = require('../services/claudeAccountService')
const unifiedOpenAIScheduler = require('../services/unifiedOpenAIScheduler')
const openaiAccountService = require('../services/openaiAccountService')
const apiKeyService = require('../services/apiKeyService')
@@ -35,13 +34,31 @@ async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel =
}
// 获取账户详情
- const account = await openaiAccountService.getAccount(result.accountId)
+ let account = await openaiAccountService.getAccount(result.accountId)
if (!account || !account.accessToken) {
throw new Error(`OpenAI account ${result.accountId} has no valid accessToken`)
}
- // 解密 accessToken
- const accessToken = claudeAccountService._decryptSensitiveData(account.accessToken)
+ // 检查 token 是否过期并自动刷新(双重保护)
+ if (openaiAccountService.isTokenExpired(account)) {
+ if (account.refreshToken) {
+ logger.info(`🔄 Token expired, auto-refreshing for account ${account.name} (fallback)`)
+ try {
+ await openaiAccountService.refreshAccountToken(result.accountId)
+ // 重新获取更新后的账户
+ account = await openaiAccountService.getAccount(result.accountId)
+ logger.info(`✅ Token refreshed successfully in route handler`)
+ } catch (refreshError) {
+ logger.error(`Failed to refresh token for ${account.name}:`, refreshError)
+ throw new Error(`Token expired and refresh failed: ${refreshError.message}`)
+ }
+ } else {
+ throw new Error(`Token expired and no refresh token available for account ${account.name}`)
+ }
+ }
+
+ // 解密 accessToken(account.accessToken 是加密的)
+ const accessToken = openaiAccountService.decrypt(account.accessToken)
if (!accessToken) {
throw new Error('Failed to decrypt OpenAI accessToken')
}
@@ -161,7 +178,7 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
// 配置请求选项
const axiosConfig = {
headers,
- timeout: 60000,
+ timeout: 60 * 1000 * 10,
validateStatus: () => true
}
diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js
index 60e0e2d2..8ee94337 100644
--- a/src/services/apiKeyService.js
+++ b/src/services/apiKeyService.js
@@ -34,7 +34,9 @@ class ApiKeyService {
allowedClients = [],
dailyCostLimit = 0,
weeklyOpusCostLimit = 0,
- tags = []
+ tags = [],
+ activationDays = 0, // 新增:激活后有效天数(0表示不使用此功能)
+ expirationMode = 'fixed' // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活)
} = options
// 生成简单的API Key (64字符十六进制)
@@ -67,9 +69,13 @@ class ApiKeyService {
dailyCostLimit: String(dailyCostLimit || 0),
weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0),
tags: JSON.stringify(tags || []),
+ activationDays: String(activationDays || 0), // 新增:激活后有效天数
+ expirationMode: expirationMode || 'fixed', // 新增:过期模式
+ isActivated: expirationMode === 'fixed' ? 'true' : 'false', // 根据模式决定激活状态
+ activatedAt: expirationMode === 'fixed' ? new Date().toISOString() : '', // 激活时间
createdAt: new Date().toISOString(),
lastUsedAt: '',
- expiresAt: expiresAt || '',
+ expiresAt: expirationMode === 'fixed' ? expiresAt || '' : '', // 固定模式才设置过期时间
createdBy: options.createdBy || 'admin',
userId: options.userId || '',
userUsername: options.userUsername || ''
@@ -105,6 +111,10 @@ class ApiKeyService {
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
tags: JSON.parse(keyData.tags || '[]'),
+ activationDays: parseInt(keyData.activationDays || 0),
+ expirationMode: keyData.expirationMode || 'fixed',
+ isActivated: keyData.isActivated === 'true',
+ activatedAt: keyData.activatedAt,
createdAt: keyData.createdAt,
expiresAt: keyData.expiresAt,
createdBy: keyData.createdBy
@@ -133,6 +143,27 @@ class ApiKeyService {
return { valid: false, error: 'API key is disabled' }
}
+ // 处理激活逻辑(仅在 activation 模式下)
+ if (keyData.expirationMode === 'activation' && keyData.isActivated !== 'true') {
+ // 首次使用,需要激活
+ const now = new Date()
+ const activationDays = parseInt(keyData.activationDays || 30) // 默认30天
+ const expiresAt = new Date(now.getTime() + activationDays * 24 * 60 * 60 * 1000)
+
+ // 更新激活状态和过期时间
+ keyData.isActivated = 'true'
+ keyData.activatedAt = now.toISOString()
+ keyData.expiresAt = expiresAt.toISOString()
+ keyData.lastUsedAt = now.toISOString()
+
+ // 保存到Redis
+ await redis.setApiKey(keyData.id, keyData)
+
+ logger.success(
+ `🔓 API key activated: ${keyData.id} (${keyData.name}), will expire in ${activationDays} days at ${expiresAt.toISOString()}`
+ )
+ }
+
// 检查是否过期
if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) {
return { valid: false, error: 'API key has expired' }
@@ -261,6 +292,10 @@ class ApiKeyService {
key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0)
key.dailyCost = (await redis.getDailyCost(key.id)) || 0
key.weeklyOpusCost = (await redis.getWeeklyOpusCost(key.id)) || 0
+ key.activationDays = parseInt(key.activationDays || 0)
+ key.expirationMode = key.expirationMode || 'fixed'
+ key.isActivated = key.isActivated === 'true'
+ key.activatedAt = key.activatedAt || null
// 获取当前时间窗口的请求次数、Token使用量和费用
if (key.rateLimitWindow > 0) {
@@ -362,6 +397,10 @@ class ApiKeyService {
'bedrockAccountId', // 添加 Bedrock 账号ID
'permissions',
'expiresAt',
+ 'activationDays', // 新增:激活后有效天数
+ 'expirationMode', // 新增:过期模式
+ 'isActivated', // 新增:是否已激活
+ 'activatedAt', // 新增:激活时间
'enableModelRestriction',
'restrictedModels',
'enableClientRestriction',
@@ -380,9 +419,16 @@ class ApiKeyService {
if (field === 'restrictedModels' || field === 'allowedClients' || field === 'tags') {
// 特殊处理数组字段
updatedData[field] = JSON.stringify(value || [])
- } else if (field === 'enableModelRestriction' || field === 'enableClientRestriction') {
+ } else if (
+ field === 'enableModelRestriction' ||
+ field === 'enableClientRestriction' ||
+ field === 'isActivated'
+ ) {
// 布尔值转字符串
updatedData[field] = String(value)
+ } else if (field === 'expiresAt' || field === 'activatedAt') {
+ // 日期字段保持原样,不要toString()
+ updatedData[field] = value || ''
} else {
updatedData[field] = (value !== null && value !== undefined ? value : '').toString()
}
diff --git a/src/services/openaiAccountService.js b/src/services/openaiAccountService.js
index e60a8b3a..eb13ac1a 100644
--- a/src/services/openaiAccountService.js
+++ b/src/services/openaiAccountService.js
@@ -14,7 +14,7 @@ const {
logRefreshSkipped
} = require('../utils/tokenRefreshLogger')
const LRUCache = require('../utils/lruCache')
-// const tokenRefreshService = require('./tokenRefreshService')
+const tokenRefreshService = require('./tokenRefreshService')
// 加密相关常量
const ALGORITHM = 'aes-256-cbc'
@@ -57,7 +57,17 @@ function encrypt(text) {
// 解密函数
function decrypt(text) {
- if (!text) {
+ if (!text || text === '') {
+ return ''
+ }
+
+ // 检查是否是有效的加密格式(至少需要 32 个字符的 IV + 冒号 + 加密文本)
+ if (text.length < 33 || text.charAt(32) !== ':') {
+ logger.warn('Invalid encrypted text format, returning empty string', {
+ textLength: text ? text.length : 0,
+ char32: text && text.length > 32 ? text.charAt(32) : 'N/A',
+ first50: text ? text.substring(0, 50) : 'N/A'
+ })
return ''
}
@@ -135,6 +145,7 @@ async function refreshAccessToken(refreshToken, proxy = null) {
const proxyAgent = ProxyHelper.createProxyAgent(proxy)
if (proxyAgent) {
requestOptions.httpsAgent = proxyAgent
+ requestOptions.proxy = false // 重要:禁用 axios 的默认代理,强制使用我们的 httpsAgent
logger.info(
`🌐 Using proxy for OpenAI token refresh: ${ProxyHelper.getProxyDescription(proxy)}`
)
@@ -143,6 +154,7 @@ async function refreshAccessToken(refreshToken, proxy = null) {
}
// 发送请求
+ logger.info('🔍 发送 token 刷新请求,使用代理:', !!requestOptions.httpsAgent)
const response = await axios(requestOptions)
if (response.status === 200 && response.data) {
@@ -164,22 +176,73 @@ async function refreshAccessToken(refreshToken, proxy = null) {
} catch (error) {
if (error.response) {
// 服务器响应了错误状态码
+ const errorData = error.response.data || {}
logger.error('OpenAI token refresh failed:', {
status: error.response.status,
- data: error.response.data,
+ data: errorData,
headers: error.response.headers
})
- throw new Error(
- `Token refresh failed: ${error.response.status} - ${JSON.stringify(error.response.data)}`
- )
+
+ // 构建详细的错误信息
+ let errorMessage = `OpenAI 服务器返回错误 (${error.response.status})`
+
+ if (error.response.status === 400) {
+ if (errorData.error === 'invalid_grant') {
+ errorMessage = 'Refresh Token 无效或已过期,请重新授权'
+ } else if (errorData.error === 'invalid_request') {
+ errorMessage = `请求参数错误:${errorData.error_description || errorData.error}`
+ } else {
+ errorMessage = `请求错误:${errorData.error_description || errorData.error || '未知错误'}`
+ }
+ } else if (error.response.status === 401) {
+ errorMessage = '认证失败:Refresh Token 无效'
+ } else if (error.response.status === 403) {
+ errorMessage = '访问被拒绝:可能是 IP 被封或账户被禁用'
+ } else if (error.response.status === 429) {
+ errorMessage = '请求过于频繁,请稍后重试'
+ } else if (error.response.status >= 500) {
+ errorMessage = 'OpenAI 服务器内部错误,请稍后重试'
+ } else if (errorData.error_description) {
+ errorMessage = errorData.error_description
+ } else if (errorData.error) {
+ errorMessage = errorData.error
+ } else if (errorData.message) {
+ errorMessage = errorData.message
+ }
+
+ const fullError = new Error(errorMessage)
+ fullError.status = error.response.status
+ fullError.details = errorData
+ throw fullError
} else if (error.request) {
// 请求已发出但没有收到响应
logger.error('OpenAI token refresh no response:', error.message)
- throw new Error(`Token refresh failed: No response from server - ${error.message}`)
+
+ let errorMessage = '无法连接到 OpenAI 服务器'
+ if (proxy) {
+ errorMessage += `(代理: ${ProxyHelper.getProxyDescription(proxy)})`
+ }
+ if (error.code === 'ECONNREFUSED') {
+ errorMessage += ' - 连接被拒绝'
+ } else if (error.code === 'ETIMEDOUT') {
+ errorMessage += ' - 连接超时'
+ } else if (error.code === 'ENOTFOUND') {
+ errorMessage += ' - 无法解析域名'
+ } else if (error.code === 'EPROTO') {
+ errorMessage += ' - 协议错误(可能是代理配置问题)'
+ } else if (error.message) {
+ errorMessage += ` - ${error.message}`
+ }
+
+ const fullError = new Error(errorMessage)
+ fullError.code = error.code
+ throw fullError
} else {
// 设置请求时发生错误
logger.error('OpenAI token refresh error:', error.message)
- throw new Error(`Token refresh failed: ${error.message}`)
+ const fullError = new Error(`请求设置错误: ${error.message}`)
+ fullError.originalError = error
+ throw fullError
}
}
}
@@ -192,34 +255,71 @@ function isTokenExpired(account) {
return new Date(account.expiresAt) <= new Date()
}
-// 刷新账户的 access token
+// 刷新账户的 access token(带分布式锁)
async function refreshAccountToken(accountId) {
- const account = await getAccount(accountId)
- if (!account) {
- throw new Error('Account not found')
- }
-
- const accountName = account.name || accountId
- logRefreshStart(accountId, accountName, 'openai')
-
- // 检查是否有 refresh token
- const refreshToken = account.refreshToken ? decrypt(account.refreshToken) : null
- if (!refreshToken) {
- logRefreshSkipped(accountId, accountName, 'openai', 'No refresh token available')
- throw new Error('No refresh token available')
- }
-
- // 获取代理配置
- let proxy = null
- if (account.proxy) {
- try {
- proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
- } catch (e) {
- logger.warn(`Failed to parse proxy config for account ${accountId}:`, e)
- }
- }
+ let lockAcquired = false
+ let account = null
+ let accountName = accountId
try {
+ account = await getAccount(accountId)
+ if (!account) {
+ throw new Error('Account not found')
+ }
+
+ accountName = account.name || accountId
+
+ // 检查是否有 refresh token
+ // account.refreshToken 在 getAccount 中已经被解密了,直接使用即可
+ const refreshToken = account.refreshToken || null
+
+ if (!refreshToken) {
+ logRefreshSkipped(accountId, accountName, 'openai', 'No refresh token available')
+ throw new Error('No refresh token available')
+ }
+
+ // 尝试获取分布式锁
+ lockAcquired = await tokenRefreshService.acquireRefreshLock(accountId, 'openai')
+
+ if (!lockAcquired) {
+ // 如果无法获取锁,说明另一个进程正在刷新
+ logger.info(
+ `🔒 Token refresh already in progress for OpenAI account: ${accountName} (${accountId})`
+ )
+ logRefreshSkipped(accountId, accountName, 'openai', 'already_locked')
+
+ // 等待一段时间后返回,期望其他进程已完成刷新
+ await new Promise((resolve) => setTimeout(resolve, 2000))
+
+ // 重新获取账户数据(可能已被其他进程刷新)
+ const updatedAccount = await getAccount(accountId)
+ if (updatedAccount && !isTokenExpired(updatedAccount)) {
+ return {
+ access_token: decrypt(updatedAccount.accessToken),
+ id_token: updatedAccount.idToken,
+ refresh_token: updatedAccount.refreshToken,
+ expires_in: 3600,
+ expiry_date: new Date(updatedAccount.expiresAt).getTime()
+ }
+ }
+
+ throw new Error('Token refresh in progress by another process')
+ }
+
+ // 获取锁成功,开始刷新
+ logRefreshStart(accountId, accountName, 'openai')
+ logger.info(`🔄 Starting token refresh for OpenAI account: ${accountName} (${accountId})`)
+
+ // 获取代理配置
+ let proxy = null
+ if (account.proxy) {
+ try {
+ proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
+ } catch (e) {
+ logger.warn(`Failed to parse proxy config for account ${accountId}:`, e)
+ }
+ }
+
const newTokens = await refreshAccessToken(refreshToken, proxy)
if (!newTokens) {
throw new Error('Failed to refresh token')
@@ -231,9 +331,51 @@ async function refreshAccountToken(accountId) {
expiresAt: new Date(newTokens.expiry_date).toISOString()
}
- // 如果有新的 ID token,也更新它
+ // 如果有新的 ID token,也更新它(这对于首次未提供 ID Token 的账户特别重要)
if (newTokens.id_token) {
updates.idToken = encrypt(newTokens.id_token)
+
+ // 如果之前没有 ID Token,尝试解析并更新用户信息
+ if (!account.idToken || account.idToken === '') {
+ try {
+ const idTokenParts = newTokens.id_token.split('.')
+ if (idTokenParts.length === 3) {
+ const payload = JSON.parse(Buffer.from(idTokenParts[1], 'base64').toString())
+ const authClaims = payload['https://api.openai.com/auth'] || {}
+
+ // 更新账户信息 - 使用正确的字段名
+ // OpenAI ID Token中用户ID在chatgpt_account_id、chatgpt_user_id和user_id字段
+ if (authClaims.chatgpt_account_id) {
+ updates.accountId = authClaims.chatgpt_account_id
+ }
+ if (authClaims.chatgpt_user_id) {
+ updates.chatgptUserId = authClaims.chatgpt_user_id
+ } else if (authClaims.user_id) {
+ // 有些情况下可能只有user_id字段
+ updates.chatgptUserId = authClaims.user_id
+ }
+ if (authClaims.organizations?.[0]?.id) {
+ updates.organizationId = authClaims.organizations[0].id
+ }
+ if (authClaims.organizations?.[0]?.role) {
+ updates.organizationRole = authClaims.organizations[0].role
+ }
+ if (authClaims.organizations?.[0]?.title) {
+ updates.organizationTitle = authClaims.organizations[0].title
+ }
+ if (payload.email) {
+ updates.email = encrypt(payload.email)
+ }
+ if (payload.email_verified !== undefined) {
+ updates.emailVerified = payload.email_verified
+ }
+
+ logger.info(`Updated user info from ID Token for account ${accountId}`)
+ }
+ } catch (e) {
+ logger.warn(`Failed to parse ID Token for account ${accountId}:`, e)
+ }
+ }
}
// 如果返回了新的 refresh token,更新它
@@ -248,8 +390,34 @@ async function refreshAccountToken(accountId) {
logRefreshSuccess(accountId, accountName, 'openai', newTokens.expiry_date)
return newTokens
} catch (error) {
- logRefreshError(accountId, accountName, 'openai', error.message)
+ logRefreshError(accountId, account?.name || accountName, 'openai', error.message)
+
+ // 发送 Webhook 通知(如果启用)
+ try {
+ const webhookNotifier = require('../utils/webhookNotifier')
+ await webhookNotifier.sendAccountAnomalyNotification({
+ accountId,
+ accountName: account?.name || accountName,
+ platform: 'openai',
+ status: 'error',
+ errorCode: 'OPENAI_TOKEN_REFRESH_FAILED',
+ reason: `Token refresh failed: ${error.message}`,
+ timestamp: new Date().toISOString()
+ })
+ logger.info(
+ `📢 Webhook notification sent for OpenAI account ${account?.name || accountName} refresh failure`
+ )
+ } catch (webhookError) {
+ logger.error('Failed to send webhook notification:', webhookError)
+ }
+
throw error
+ } finally {
+ // 确保释放锁
+ if (lockAcquired) {
+ await tokenRefreshService.releaseRefreshLock(accountId, 'openai')
+ logger.debug(`🔓 Released refresh lock for OpenAI account ${accountId}`)
+ }
}
}
@@ -270,6 +438,10 @@ async function createAccount(accountData) {
// 处理账户信息
const accountInfo = accountData.accountInfo || {}
+ // 检查邮箱是否已经是加密格式(包含冒号分隔的32位十六进制字符)
+ const isEmailEncrypted =
+ accountInfo.email && accountInfo.email.length >= 33 && accountInfo.email.charAt(32) === ':'
+
const account = {
id: accountId,
name: accountData.name,
@@ -282,19 +454,25 @@ async function createAccount(accountData) {
? accountData.rateLimitDuration
: 60,
// OAuth相关字段(加密存储)
- idToken: encrypt(oauthData.idToken || ''),
- accessToken: encrypt(oauthData.accessToken || ''),
- refreshToken: encrypt(oauthData.refreshToken || ''),
+ // ID Token 现在是可选的,如果没有提供会在首次刷新时自动获取
+ idToken: oauthData.idToken && oauthData.idToken.trim() ? encrypt(oauthData.idToken) : '',
+ accessToken:
+ oauthData.accessToken && oauthData.accessToken.trim() ? encrypt(oauthData.accessToken) : '',
+ refreshToken:
+ oauthData.refreshToken && oauthData.refreshToken.trim()
+ ? encrypt(oauthData.refreshToken)
+ : '',
openaiOauth: encrypt(JSON.stringify(oauthData)),
- // 账户信息字段
+ // 账户信息字段 - 确保所有字段都被保存,即使是空字符串
accountId: accountInfo.accountId || '',
chatgptUserId: accountInfo.chatgptUserId || '',
organizationId: accountInfo.organizationId || '',
organizationRole: accountInfo.organizationRole || '',
organizationTitle: accountInfo.organizationTitle || '',
planType: accountInfo.planType || '',
- email: encrypt(accountInfo.email || ''),
- emailVerified: accountInfo.emailVerified || false,
+ // 邮箱字段:检查是否已经加密,避免双重加密
+ email: isEmailEncrypted ? accountInfo.email : encrypt(accountInfo.email || ''),
+ emailVerified: accountInfo.emailVerified === true ? 'true' : 'false',
// 过期时间
expiresAt: oauthData.expires_in
? new Date(Date.now() + oauthData.expires_in * 1000).toISOString()
@@ -339,9 +517,10 @@ async function getAccount(accountId) {
if (accountData.idToken) {
accountData.idToken = decrypt(accountData.idToken)
}
- if (accountData.accessToken) {
- accountData.accessToken = decrypt(accountData.accessToken)
- }
+ // 注意:accessToken 在 openaiRoutes.js 中会被单独解密,这里不解密
+ // if (accountData.accessToken) {
+ // accountData.accessToken = decrypt(accountData.accessToken)
+ // }
if (accountData.refreshToken) {
accountData.refreshToken = decrypt(accountData.refreshToken)
}
@@ -391,7 +570,7 @@ async function updateAccount(accountId, updates) {
if (updates.accessToken) {
updates.accessToken = encrypt(updates.accessToken)
}
- if (updates.refreshToken) {
+ if (updates.refreshToken && updates.refreshToken.trim()) {
updates.refreshToken = encrypt(updates.refreshToken)
}
if (updates.email) {
@@ -476,6 +655,9 @@ async function getAllAccounts() {
accountData.email = decrypt(accountData.email)
}
+ // 先保存 refreshToken 是否存在的标记
+ const hasRefreshTokenFlag = !!accountData.refreshToken
+
// 屏蔽敏感信息(token等不应该返回给前端)
delete accountData.idToken
delete accountData.accessToken
@@ -512,7 +694,7 @@ async function getAllAccounts() {
scopes:
accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [],
// 添加 hasRefreshToken 标记
- hasRefreshToken: !!accountData.refreshToken,
+ hasRefreshToken: hasRefreshTokenFlag,
// 添加限流状态信息(统一格式)
rateLimitStatus: rateLimitInfo
? {
@@ -640,6 +822,26 @@ async function setAccountRateLimited(accountId, isLimited) {
await updateAccount(accountId, updates)
logger.info(`Set rate limit status for OpenAI account ${accountId}: ${updates.rateLimitStatus}`)
+
+ // 如果被限流,发送 Webhook 通知
+ if (isLimited) {
+ try {
+ const account = await getAccount(accountId)
+ const webhookNotifier = require('../utils/webhookNotifier')
+ await webhookNotifier.sendAccountAnomalyNotification({
+ accountId,
+ accountName: account.name || accountId,
+ platform: 'openai',
+ status: 'blocked',
+ errorCode: 'OPENAI_RATE_LIMITED',
+ reason: 'Account rate limited (429 error). Estimated reset in 1 hour',
+ timestamp: new Date().toISOString()
+ })
+ logger.info(`📢 Webhook notification sent for OpenAI account ${account.name} rate limit`)
+ } catch (webhookError) {
+ logger.error('Failed to send rate limit webhook notification:', webhookError)
+ }
+ }
}
// 切换账户调度状态
diff --git a/src/services/unifiedOpenAIScheduler.js b/src/services/unifiedOpenAIScheduler.js
index 153f75b0..85404543 100644
--- a/src/services/unifiedOpenAIScheduler.js
+++ b/src/services/unifiedOpenAIScheduler.js
@@ -167,7 +167,7 @@ class UnifiedOpenAIScheduler {
// 获取所有OpenAI账户(共享池)
const openaiAccounts = await openaiAccountService.getAllAccounts()
- for (const account of openaiAccounts) {
+ for (let account of openaiAccounts) {
if (
account.isActive &&
account.status !== 'error' &&
@@ -176,13 +176,27 @@ class UnifiedOpenAIScheduler {
) {
// 检查是否可调度
- // 检查token是否过期
+ // 检查token是否过期并自动刷新
const isExpired = openaiAccountService.isTokenExpired(account)
- if (isExpired && !account.refreshToken) {
- logger.warn(
- `⚠️ OpenAI account ${account.name} token expired and no refresh token available`
- )
- continue
+ if (isExpired) {
+ if (!account.refreshToken) {
+ logger.warn(
+ `⚠️ OpenAI account ${account.name} token expired and no refresh token available`
+ )
+ continue
+ }
+
+ // 自动刷新过期的 token
+ try {
+ logger.info(`🔄 Auto-refreshing expired token for OpenAI account ${account.name}`)
+ await openaiAccountService.refreshAccountToken(account.id)
+ // 重新获取更新后的账户信息
+ account = await openaiAccountService.getAccount(account.id)
+ logger.info(`✅ Token refreshed successfully for ${account.name}`)
+ } catch (refreshError) {
+ logger.error(`❌ Failed to refresh token for ${account.name}:`, refreshError.message)
+ continue // 刷新失败,跳过此账户
+ }
}
// 检查模型支持(仅在明确设置了supportedModels且不为空时才检查)
diff --git a/src/utils/webhookNotifier.js b/src/utils/webhookNotifier.js
index dccbb878..34a4976d 100644
--- a/src/utils/webhookNotifier.js
+++ b/src/utils/webhookNotifier.js
@@ -81,6 +81,12 @@ class WebhookNotifier {
error: 'GEMINI_ERROR',
unauthorized: 'GEMINI_UNAUTHORIZED',
disabled: 'GEMINI_MANUALLY_DISABLED'
+ },
+ openai: {
+ error: 'OPENAI_ERROR',
+ unauthorized: 'OPENAI_UNAUTHORIZED',
+ blocked: 'OPENAI_RATE_LIMITED',
+ disabled: 'OPENAI_MANUALLY_DISABLED'
}
}
diff --git a/web/admin-spa/src/assets/styles/global.css b/web/admin-spa/src/assets/styles/global.css
index a97e9bf4..3c921320 100644
--- a/web/admin-spa/src/assets/styles/global.css
+++ b/web/admin-spa/src/assets/styles/global.css
@@ -17,7 +17,7 @@
--bg-gradient-mid: #764ba2;
--bg-gradient-end: #f093fb;
--input-bg: rgba(255, 255, 255, 0.9);
- --input-border: rgba(255, 255, 255, 0.3);
+ --input-border: rgba(209, 213, 219, 0.8);
--modal-bg: rgba(0, 0, 0, 0.4);
--table-bg: rgba(255, 255, 255, 0.95);
--table-hover: rgba(102, 126, 234, 0.05);
diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue
index 0d4c3226..d47a4ba5 100644
--- a/web/admin-spa/src/components/accounts/AccountForm.vue
+++ b/web/admin-spa/src/components/accounts/AccountForm.vue
@@ -176,7 +176,7 @@
>
@@ -300,7 +300,7 @@
>
@@ -351,7 +351,7 @@
>
@@ -434,7 +434,7 @@
>
@@ -463,7 +463,7 @@
>
@@ -481,7 +481,7 @@
>
@@ -516,7 +516,7 @@
>
@@ -713,14 +713,14 @@
>
@@ -794,7 +794,7 @@
>
@@ -827,7 +827,7 @@
>
-
Access Token (可选)
-
- {{ errors.idToken }}
-
- ID Token 是 OpenAI OAuth 认证返回的 JWT token,包含用户信息和组织信息
+
+ Access Token 可选填。如果不提供,系统会通过 Refresh Token 自动获取。
-
+
-
+
+
+
+
+ {{ errors.refreshToken }}
+
+
+
+ 系统将使用 Refresh Token 自动获取 Access Token 和用户信息
+
+
+
+
@@ -1265,7 +1281,7 @@
@@ -1313,7 +1329,7 @@
>
@@ -1433,7 +1449,7 @@
>
@@ -1544,7 +1560,7 @@
>
@@ -1910,7 +1926,7 @@
>
@@ -1941,7 +1957,7 @@
>
@@ -2044,7 +2060,7 @@
>
@@ -2180,7 +2196,6 @@ const form = ref({
groupId: '',
groupIds: [],
projectId: props.account?.projectId || '',
- idToken: '',
accessToken: '',
refreshToken: '',
proxy: initProxyConfig(),
@@ -2249,7 +2264,7 @@ const initModelMappings = () => {
// 表单验证错误
const errors = ref({
name: '',
- idToken: '',
+ refreshToken: '',
accessToken: '',
apiUrl: '',
apiKey: '',
@@ -2530,7 +2545,35 @@ const handleOAuthSuccess = async (tokenInfo) => {
emit('success', result)
} catch (error) {
- showToast(error.message || '账户创建失败', 'error')
+ // 显示详细的错误信息
+ const errorMessage = error.response?.data?.error || error.message || '账户创建失败'
+ const suggestion = error.response?.data?.suggestion || ''
+ const errorDetails = error.response?.data?.errorDetails || null
+
+ // 构建完整的错误提示
+ let fullMessage = errorMessage
+ if (suggestion) {
+ fullMessage += `\n${suggestion}`
+ }
+
+ // 如果有详细的 OAuth 错误信息,也显示出来
+ if (errorDetails && errorDetails.error_description) {
+ fullMessage += `\n详细信息: ${errorDetails.error_description}`
+ } else if (errorDetails && errorDetails.error && errorDetails.error.message) {
+ // 处理 OpenAI 格式的错误
+ fullMessage += `\n详细信息: ${errorDetails.error.message}`
+ }
+
+ showToast(fullMessage, 'error', '', 8000)
+
+ // 在控制台打印完整的错误信息以便调试
+ console.error('账户创建失败:', {
+ message: errorMessage,
+ suggestion,
+ errorDetails,
+ errorCode: error.response?.data?.errorCode,
+ networkError: error.response?.data?.networkError
+ })
} finally {
loading.value = false
}
@@ -2591,17 +2634,19 @@ const createAccount = async () => {
}
} else if (form.value.addType === 'manual') {
// 手动模式验证
- if (!form.value.accessToken || form.value.accessToken.trim() === '') {
- errors.value.accessToken = '请填写 Access Token'
- hasError = true
- }
- // OpenAI 平台需要验证 ID Token
- if (
- form.value.platform === 'openai' &&
- (!form.value.idToken || form.value.idToken.trim() === '')
- ) {
- errors.value.idToken = '请填写 ID Token'
- hasError = true
+ if (form.value.platform === 'openai') {
+ // OpenAI 平台必须有 Refresh Token
+ if (!form.value.refreshToken || form.value.refreshToken.trim() === '') {
+ errors.value.refreshToken = '请填写 Refresh Token'
+ hasError = true
+ }
+ // Access Token 可选,如果没有会通过 Refresh Token 获取
+ } else {
+ // 其他平台(Gemini)需要 Access Token
+ if (!form.value.accessToken || form.value.accessToken.trim() === '') {
+ errors.value.accessToken = '请填写 Access Token'
+ hasError = true
+ }
}
}
@@ -2695,14 +2740,14 @@ const createAccount = async () => {
: 365 * 24 * 60 * 60 * 1000 // 1年
data.openaiOauth = {
- idToken: form.value.idToken, // 使用用户输入的 ID Token
- accessToken: form.value.accessToken,
- refreshToken: form.value.refreshToken || '',
+ idToken: '', // 不再需要用户输入,系统会自动获取
+ accessToken: form.value.accessToken || '', // Access Token 可选
+ refreshToken: form.value.refreshToken, // Refresh Token 必填
expires_in: Math.floor(expiresInMs / 1000) // 转换为秒
}
- // 手动模式下,尝试从 ID Token 解析用户信息
- let accountInfo = {
+ // 账户信息将在首次刷新时自动获取
+ data.accountInfo = {
accountId: '',
chatgptUserId: '',
organizationId: '',
@@ -2713,31 +2758,9 @@ const createAccount = async () => {
emailVerified: false
}
- // 尝试解析 ID Token (JWT)
- if (form.value.idToken) {
- try {
- const idTokenParts = form.value.idToken.split('.')
- if (idTokenParts.length === 3) {
- const payload = JSON.parse(atob(idTokenParts[1]))
- const authClaims = payload['https://api.openai.com/auth'] || {}
-
- accountInfo = {
- accountId: authClaims.accountId || '',
- chatgptUserId: authClaims.chatgptUserId || '',
- organizationId: authClaims.organizationId || '',
- organizationRole: authClaims.organizationRole || '',
- organizationTitle: authClaims.organizationTitle || '',
- planType: authClaims.planType || '',
- email: payload.email || '',
- emailVerified: payload.email_verified || false
- }
- }
- } catch (e) {
- console.warn('Failed to parse ID Token:', e)
- }
- }
-
- data.accountInfo = accountInfo
+ // OpenAI 手动模式必须刷新以获取完整信息(包括 ID Token)
+ data.needsImmediateRefresh = true
+ data.requireRefreshSuccess = true // 必须刷新成功才能创建账户
data.priority = form.value.priority || 50
} else if (form.value.platform === 'claude-console') {
// Claude Console 账户特定数据
@@ -2797,7 +2820,35 @@ const createAccount = async () => {
emit('success', result)
} catch (error) {
- showToast(error.message || '账户创建失败', 'error')
+ // 显示详细的错误信息
+ const errorMessage = error.response?.data?.error || error.message || '账户创建失败'
+ const suggestion = error.response?.data?.suggestion || ''
+ const errorDetails = error.response?.data?.errorDetails || null
+
+ // 构建完整的错误提示
+ let fullMessage = errorMessage
+ if (suggestion) {
+ fullMessage += `\n${suggestion}`
+ }
+
+ // 如果有详细的 OAuth 错误信息,也显示出来
+ if (errorDetails && errorDetails.error_description) {
+ fullMessage += `\n详细信息: ${errorDetails.error_description}`
+ } else if (errorDetails && errorDetails.error && errorDetails.error.message) {
+ // 处理 OpenAI 格式的错误
+ fullMessage += `\n详细信息: ${errorDetails.error.message}`
+ }
+
+ showToast(fullMessage, 'error', '', 8000)
+
+ // 在控制台打印完整的错误信息以便调试
+ console.error('账户创建失败:', {
+ message: errorMessage,
+ suggestion,
+ errorDetails,
+ errorCode: error.response?.data?.errorCode,
+ networkError: error.response?.data?.networkError
+ })
} finally {
loading.value = false
}
@@ -2901,11 +2952,17 @@ const updateAccount = async () => {
: 365 * 24 * 60 * 60 * 1000 // 1年
data.openaiOauth = {
- idToken: form.value.idToken || '', // 更新时使用用户输入的 ID Token
+ idToken: '', // 不需要用户输入
accessToken: form.value.accessToken || '',
refreshToken: form.value.refreshToken || '',
expires_in: Math.floor(expiresInMs / 1000) // 转换为秒
}
+
+ // 编辑 OpenAI 账户时,如果更新了 Refresh Token,也需要验证
+ if (form.value.refreshToken && form.value.refreshToken !== props.account.refreshToken) {
+ data.needsImmediateRefresh = true
+ data.requireRefreshSuccess = true
+ }
}
}
@@ -3012,7 +3069,35 @@ const updateAccount = async () => {
emit('success')
} catch (error) {
- showToast(error.message || '账户更新失败', 'error')
+ // 显示详细的错误信息
+ const errorMessage = error.response?.data?.error || error.message || '账户更新失败'
+ const suggestion = error.response?.data?.suggestion || ''
+ const errorDetails = error.response?.data?.errorDetails || null
+
+ // 构建完整的错误提示
+ let fullMessage = errorMessage
+ if (suggestion) {
+ fullMessage += `\n${suggestion}`
+ }
+
+ // 如果有详细的 OAuth 错误信息,也显示出来
+ if (errorDetails && errorDetails.error_description) {
+ fullMessage += `\n详细信息: ${errorDetails.error_description}`
+ } else if (errorDetails && errorDetails.error && errorDetails.error.message) {
+ // 处理 OpenAI 格式的错误
+ fullMessage += `\n详细信息: ${errorDetails.error.message}`
+ }
+
+ showToast(fullMessage, 'error', '', 8000)
+
+ // 在控制台打印完整的错误信息以便调试
+ console.error('账户更新失败:', {
+ message: errorMessage,
+ suggestion,
+ errorDetails,
+ errorCode: error.response?.data?.errorCode,
+ networkError: error.response?.data?.networkError
+ })
} finally {
loading.value = false
}
diff --git a/web/admin-spa/src/components/accounts/ProxyConfig.vue b/web/admin-spa/src/components/accounts/ProxyConfig.vue
index 1727dd6c..d38bb28b 100644
--- a/web/admin-spa/src/components/accounts/ProxyConfig.vue
+++ b/web/admin-spa/src/components/accounts/ProxyConfig.vue
@@ -36,7 +36,7 @@
>
|