Merge remote-tracking branch 'f3n9/main' into user-management-new

This commit is contained in:
Feng Yue
2025-08-18 15:32:17 +08:00
34 changed files with 2797 additions and 335 deletions

View File

@@ -391,6 +391,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
claudeConsoleAccountId,
geminiAccountId,
openaiAccountId,
bedrockAccountId,
permissions,
concurrencyLimit,
rateLimitWindow,
@@ -487,6 +488,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
claudeConsoleAccountId,
geminiAccountId,
openaiAccountId,
bedrockAccountId,
permissions,
concurrencyLimit,
rateLimitWindow,
@@ -633,6 +635,7 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
claudeConsoleAccountId,
geminiAccountId,
openaiAccountId,
bedrockAccountId,
permissions,
enableModelRestriction,
restrictedModels,
@@ -696,6 +699,11 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
updates.openaiAccountId = openaiAccountId || ''
}
if (bedrockAccountId !== undefined) {
// 空字符串表示解绑null或空字符串都设置为空字符串
updates.bedrockAccountId = bedrockAccountId || ''
}
if (permissions !== undefined) {
// 验证权限值
if (!['claude', 'gemini', 'openai', 'all'].includes(permissions)) {
@@ -1402,6 +1410,46 @@ router.delete('/claude-accounts/:accountId', authenticateAdmin, async (req, res)
}
})
// 更新单个Claude账户的Profile信息
router.post('/claude-accounts/:accountId/update-profile', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const profileInfo = await claudeAccountService.fetchAndUpdateAccountProfile(accountId)
logger.success(`✅ Updated profile for Claude account: ${accountId}`)
return res.json({
success: true,
message: 'Account profile updated successfully',
data: profileInfo
})
} catch (error) {
logger.error('❌ Failed to update account profile:', error)
return res
.status(500)
.json({ error: 'Failed to update account profile', message: error.message })
}
})
// 批量更新所有Claude账户的Profile信息
router.post('/claude-accounts/update-all-profiles', authenticateAdmin, async (req, res) => {
try {
const result = await claudeAccountService.updateAllAccountProfiles()
logger.success('✅ Batch profile update completed')
return res.json({
success: true,
message: 'Batch profile update completed',
data: result
})
} catch (error) {
logger.error('❌ Failed to update all account profiles:', error)
return res
.status(500)
.json({ error: 'Failed to update all account profiles', message: error.message })
}
})
// 刷新Claude账户token
router.post('/claude-accounts/:accountId/refresh', authenticateAdmin, async (req, res) => {
try {

View File

@@ -96,22 +96,42 @@ async function handleMessagesRequest(req, res) {
) {
const inputTokens = usageData.input_tokens || 0
const outputTokens = usageData.output_tokens || 0
const cacheCreateTokens = usageData.cache_creation_input_tokens || 0
// 兼容处理:如果有详细的 cache_creation 对象,使用它;否则使用总的 cache_creation_input_tokens
let cacheCreateTokens = usageData.cache_creation_input_tokens || 0
let ephemeral5mTokens = 0
let ephemeral1hTokens = 0
if (usageData.cache_creation && typeof usageData.cache_creation === 'object') {
ephemeral5mTokens = usageData.cache_creation.ephemeral_5m_input_tokens || 0
ephemeral1hTokens = usageData.cache_creation.ephemeral_1h_input_tokens || 0
// 总的缓存创建 tokens 是两者之和
cacheCreateTokens = ephemeral5mTokens + ephemeral1hTokens
}
const cacheReadTokens = usageData.cache_read_input_tokens || 0
const model = usageData.model || 'unknown'
// 记录真实的token使用量包含模型信息和所有4种token以及账户ID
const { accountId: usageAccountId } = usageData
// 构建 usage 对象以传递给 recordUsage
const usageObject = {
input_tokens: inputTokens,
output_tokens: outputTokens,
cache_creation_input_tokens: cacheCreateTokens,
cache_read_input_tokens: cacheReadTokens
}
// 如果有详细的缓存创建数据,添加到 usage 对象中
if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) {
usageObject.cache_creation = {
ephemeral_5m_input_tokens: ephemeral5mTokens,
ephemeral_1h_input_tokens: ephemeral1hTokens
}
}
apiKeyService
.recordUsage(
req.apiKey.id,
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens,
model,
usageAccountId
)
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId)
.catch((error) => {
logger.error('❌ Failed to record stream usage:', error)
})
@@ -161,22 +181,42 @@ async function handleMessagesRequest(req, res) {
) {
const inputTokens = usageData.input_tokens || 0
const outputTokens = usageData.output_tokens || 0
const cacheCreateTokens = usageData.cache_creation_input_tokens || 0
// 兼容处理:如果有详细的 cache_creation 对象,使用它;否则使用总的 cache_creation_input_tokens
let cacheCreateTokens = usageData.cache_creation_input_tokens || 0
let ephemeral5mTokens = 0
let ephemeral1hTokens = 0
if (usageData.cache_creation && typeof usageData.cache_creation === 'object') {
ephemeral5mTokens = usageData.cache_creation.ephemeral_5m_input_tokens || 0
ephemeral1hTokens = usageData.cache_creation.ephemeral_1h_input_tokens || 0
// 总的缓存创建 tokens 是两者之和
cacheCreateTokens = ephemeral5mTokens + ephemeral1hTokens
}
const cacheReadTokens = usageData.cache_read_input_tokens || 0
const model = usageData.model || 'unknown'
// 记录真实的token使用量包含模型信息和所有4种token以及账户ID
const usageAccountId = usageData.accountId
// 构建 usage 对象以传递给 recordUsage
const usageObject = {
input_tokens: inputTokens,
output_tokens: outputTokens,
cache_creation_input_tokens: cacheCreateTokens,
cache_read_input_tokens: cacheReadTokens
}
// 如果有详细的缓存创建数据,添加到 usage 对象中
if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) {
usageObject.cache_creation = {
ephemeral_5m_input_tokens: ephemeral5mTokens,
ephemeral_1h_input_tokens: ephemeral1hTokens
}
}
apiKeyService
.recordUsage(
req.apiKey.id,
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens,
model,
usageAccountId
)
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId)
.catch((error) => {
logger.error('❌ Failed to record stream usage:', error)
})

View File

@@ -318,19 +318,38 @@ async function handleLoadCodeAssist(req, res) {
sessionHash,
requestedModel
)
const { accessToken, refreshToken } = await geminiAccountService.getAccount(accountId)
const account = await geminiAccountService.getAccount(accountId)
const { accessToken, refreshToken, projectId } = account
const { metadata, cloudaicompanionProject } = req.body
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
logger.info(`LoadCodeAssist request (${version})`, {
metadata: metadata || {},
cloudaicompanionProject: cloudaicompanionProject || null,
requestedProject: cloudaicompanionProject || null,
accountProject: projectId || null,
apiKeyId: req.apiKey?.id || 'unknown'
})
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
const response = await geminiAccountService.loadCodeAssist(client, cloudaicompanionProject)
// 根据账户配置决定项目ID
// 1. 如果账户有项目ID -> 使用账户的项目ID强制覆盖
// 2. 如果账户没有项目ID -> 传递 null移除项目ID
let effectiveProjectId = null
if (projectId) {
// 账户配置了项目ID强制使用它
effectiveProjectId = projectId
logger.info('Using account project ID for loadCodeAssist:', effectiveProjectId)
} else {
// 账户没有配置项目ID确保不传递项目ID
effectiveProjectId = null
logger.info('No project ID in account for loadCodeAssist, removing project parameter')
}
const response = await geminiAccountService.loadCodeAssist(client, effectiveProjectId)
res.json(response)
} catch (error) {
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
@@ -345,6 +364,7 @@ async function handleLoadCodeAssist(req, res) {
// 共用的 onboardUser 处理函数
async function handleOnboardUser(req, res) {
try {
// 提取请求参数
const { tierId, cloudaicompanionProject, metadata } = req.body
const sessionHash = sessionHelper.generateSessionHash(req.body)
@@ -355,34 +375,53 @@ async function handleOnboardUser(req, res) {
sessionHash,
requestedModel
)
const { accessToken, refreshToken } = await geminiAccountService.getAccount(accountId)
const account = await geminiAccountService.getAccount(accountId)
const { accessToken, refreshToken, projectId } = account
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
logger.info(`OnboardUser request (${version})`, {
tierId: tierId || 'not provided',
cloudaicompanionProject: cloudaicompanionProject || null,
requestedProject: cloudaicompanionProject || null,
accountProject: projectId || null,
metadata: metadata || {},
apiKeyId: req.apiKey?.id || 'unknown'
})
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
// 如果提供了完整参数直接调用onboardUser
if (tierId && metadata) {
// 根据账户配置决定项目ID
// 1. 如果账户有项目ID -> 使用账户的项目ID强制覆盖
// 2. 如果账户没有项目ID -> 传递 null移除项目ID
let effectiveProjectId = null
if (projectId) {
// 账户配置了项目ID强制使用它
effectiveProjectId = projectId
logger.info('Using account project ID:', effectiveProjectId)
} else {
// 账户没有配置项目ID确保不传递项目ID即使客户端传了也要移除
effectiveProjectId = null
logger.info('No project ID in account, removing project parameter')
}
// 如果提供了 tierId直接调用 onboardUser
if (tierId) {
const response = await geminiAccountService.onboardUser(
client,
tierId,
cloudaicompanionProject,
effectiveProjectId, // 使用处理后的项目ID
metadata
)
res.json(response)
} else {
// 否则执行完整的setupUser流程
// 否则执行完整的 setupUser 流程
const response = await geminiAccountService.setupUser(
client,
cloudaicompanionProject,
effectiveProjectId, // 使用处理后的项目ID
metadata
)
res.json(response)
}
} catch (error) {
@@ -506,7 +545,7 @@ async function handleGenerateContent(req, res) {
client,
{ model, request: actualRequestData },
user_prompt_id,
project || account.projectId,
account.projectId, // 始终使用账户配置的项目ID忽略请求中的project
req.apiKey?.id // 使用 API Key ID 作为 session ID
)
@@ -533,7 +572,6 @@ async function handleGenerateContent(req, res) {
res.json(response)
} catch (error) {
console.log(321, error.response)
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
logger.error(`Error in generateContent endpoint (${version})`, { error: error.message })
res.status(500).json({
@@ -620,7 +658,7 @@ async function handleStreamGenerateContent(req, res) {
client,
{ model, request: actualRequestData },
user_prompt_id,
project || account.projectId,
account.projectId, // 始终使用账户配置的项目ID忽略请求中的project
req.apiKey?.id, // 使用 API Key ID 作为 session ID
abortController.signal // 传递中止信号
)

View File

@@ -250,19 +250,13 @@ async function handleChatCompletion(req, res, apiKeyData) {
(usage) => {
// 记录使用统计
if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) {
const inputTokens = usage.input_tokens || 0
const outputTokens = usage.output_tokens || 0
const cacheCreateTokens = usage.cache_creation_input_tokens || 0
const cacheReadTokens = usage.cache_read_input_tokens || 0
const model = usage.model || claudeRequest.model
// 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据
apiKeyService
.recordUsage(
.recordUsageWithDetails(
apiKeyData.id,
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens,
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
model,
accountId
)
@@ -328,13 +322,11 @@ async function handleChatCompletion(req, res, apiKeyData) {
// 记录使用统计
if (claudeData.usage) {
const { usage } = claudeData
// 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据
apiKeyService
.recordUsage(
.recordUsageWithDetails(
apiKeyData.id,
usage.input_tokens || 0,
usage.output_tokens || 0,
usage.cache_creation_input_tokens || 0,
usage.cache_read_input_tokens || 0,
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
claudeRequest.model,
accountId
)

View File

@@ -128,7 +128,8 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
'max_output_tokens',
'user',
'text_formatting',
'truncation'
'truncation',
'service_tier'
]
fieldsToRemove.forEach((field) => {
delete req.body[field]

120
src/routes/webhook.js Normal file
View File

@@ -0,0 +1,120 @@
const express = require('express')
const router = express.Router()
const logger = require('../utils/logger')
const webhookNotifier = require('../utils/webhookNotifier')
const { authenticateAdmin } = require('../middleware/auth')
// 测试Webhook连通性
router.post('/test', authenticateAdmin, async (req, res) => {
try {
const { url } = req.body
if (!url) {
return res.status(400).json({
error: 'Missing webhook URL',
message: 'Please provide a webhook URL to test'
})
}
// 验证URL格式
try {
new URL(url)
} catch (urlError) {
return res.status(400).json({
error: 'Invalid URL format',
message: 'Please provide a valid webhook URL'
})
}
logger.info(`🧪 Testing webhook URL: ${url}`)
const result = await webhookNotifier.testWebhook(url)
if (result.success) {
logger.info(`✅ Webhook test successful for: ${url}`)
res.json({
success: true,
message: 'Webhook test successful',
url
})
} else {
logger.warn(`❌ Webhook test failed for: ${url} - ${result.error}`)
res.status(400).json({
success: false,
message: 'Webhook test failed',
url,
error: result.error
})
}
} catch (error) {
logger.error('❌ Webhook test error:', error)
res.status(500).json({
error: 'Internal server error',
message: 'Failed to test webhook'
})
}
})
// 手动触发账号异常通知(用于测试)
router.post('/test-notification', authenticateAdmin, async (req, res) => {
try {
const {
accountId = 'test-account-id',
accountName = 'Test Account',
platform = 'claude-oauth',
status = 'error',
errorCode = 'TEST_ERROR',
reason = 'Manual test notification'
} = req.body
logger.info(`🧪 Sending test notification for account: ${accountName}`)
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName,
platform,
status,
errorCode,
reason
})
logger.info(`✅ Test notification sent successfully`)
res.json({
success: true,
message: 'Test notification sent successfully',
data: {
accountId,
accountName,
platform,
status,
errorCode,
reason
}
})
} catch (error) {
logger.error('❌ Failed to send test notification:', error)
res.status(500).json({
error: 'Internal server error',
message: 'Failed to send test notification'
})
}
})
// 获取Webhook配置信息
router.get('/config', authenticateAdmin, (req, res) => {
const config = require('../../config/config')
res.json({
success: true,
config: {
enabled: config.webhook?.enabled !== false,
urls: config.webhook?.urls || [],
timeout: config.webhook?.timeout || 10000,
retries: config.webhook?.retries || 3,
urlCount: (config.webhook?.urls || []).length
}
})
})
module.exports = router