mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
主要改进: 1. 创建 mapExpiryField() 工具函数统一处理前后端字段映射(expiresAt -> subscriptionExpiresAt) 2. 统一 subscriptionExpiresAt 初始值为 null(替代空字符串) 3. 规范过期检查方法名为 isSubscriptionExpired(),返回 true 表示已过期 4. 优化过期检查条件判断,只检查 null 而非空字符串 5. 补充 OpenAI-Responses 和调度器中缺失的过期检查逻辑 6. 添加代码评审文档记录未修复问题 影响范围: - 所有 9 种账户服务的过期字段处理 - admin.js 中所有账户更新路由 - 统一调度器的过期账户过滤逻辑 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
524 lines
15 KiB
JavaScript
524 lines
15 KiB
JavaScript
const redisClient = require('../models/redis')
|
||
const { v4: uuidv4 } = require('uuid')
|
||
const crypto = require('crypto')
|
||
const config = require('../../config/config')
|
||
const logger = require('../utils/logger')
|
||
|
||
// 加密相关常量
|
||
const ALGORITHM = 'aes-256-cbc'
|
||
const IV_LENGTH = 16
|
||
|
||
// 🚀 安全的加密密钥生成,支持动态salt
|
||
const ENCRYPTION_SALT = config.security?.azureOpenaiSalt || 'azure-openai-account-default-salt'
|
||
|
||
class EncryptionKeyManager {
|
||
constructor() {
|
||
this.keyCache = new Map()
|
||
this.keyRotationInterval = 24 * 60 * 60 * 1000 // 24小时
|
||
}
|
||
|
||
getKey(version = 'current') {
|
||
const cached = this.keyCache.get(version)
|
||
if (cached && Date.now() - cached.timestamp < this.keyRotationInterval) {
|
||
return cached.key
|
||
}
|
||
|
||
// 生成新密钥
|
||
const key = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
|
||
this.keyCache.set(version, {
|
||
key,
|
||
timestamp: Date.now()
|
||
})
|
||
|
||
logger.debug('🔑 Azure OpenAI encryption key generated/refreshed')
|
||
return key
|
||
}
|
||
|
||
// 清理过期密钥
|
||
cleanup() {
|
||
const now = Date.now()
|
||
for (const [version, cached] of this.keyCache.entries()) {
|
||
if (now - cached.timestamp > this.keyRotationInterval) {
|
||
this.keyCache.delete(version)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const encryptionKeyManager = new EncryptionKeyManager()
|
||
|
||
// 定期清理过期密钥
|
||
setInterval(
|
||
() => {
|
||
encryptionKeyManager.cleanup()
|
||
},
|
||
60 * 60 * 1000
|
||
) // 每小时清理一次
|
||
|
||
// 生成加密密钥 - 使用安全的密钥管理器
|
||
function generateEncryptionKey() {
|
||
return encryptionKeyManager.getKey()
|
||
}
|
||
|
||
// Azure OpenAI 账户键前缀
|
||
const AZURE_OPENAI_ACCOUNT_KEY_PREFIX = 'azure_openai:account:'
|
||
const SHARED_AZURE_OPENAI_ACCOUNTS_KEY = 'shared_azure_openai_accounts'
|
||
const ACCOUNT_SESSION_MAPPING_PREFIX = 'azure_openai_session_account_mapping:'
|
||
|
||
// 加密函数
|
||
function encrypt(text) {
|
||
if (!text) {
|
||
return ''
|
||
}
|
||
const key = generateEncryptionKey()
|
||
const iv = crypto.randomBytes(IV_LENGTH)
|
||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
||
let encrypted = cipher.update(text)
|
||
encrypted = Buffer.concat([encrypted, cipher.final()])
|
||
return `${iv.toString('hex')}:${encrypted.toString('hex')}`
|
||
}
|
||
|
||
// 解密函数 - 移除缓存以提高安全性
|
||
function decrypt(text) {
|
||
if (!text) {
|
||
return ''
|
||
}
|
||
|
||
try {
|
||
const key = generateEncryptionKey()
|
||
// IV 是固定长度的 32 个十六进制字符(16 字节)
|
||
const ivHex = text.substring(0, 32)
|
||
const encryptedHex = text.substring(33) // 跳过冒号
|
||
|
||
if (ivHex.length !== 32 || !encryptedHex) {
|
||
throw new Error('Invalid encrypted text format')
|
||
}
|
||
|
||
const iv = Buffer.from(ivHex, 'hex')
|
||
const encryptedText = Buffer.from(encryptedHex, 'hex')
|
||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||
let decrypted = decipher.update(encryptedText)
|
||
decrypted = Buffer.concat([decrypted, decipher.final()])
|
||
const result = decrypted.toString()
|
||
|
||
return result
|
||
} catch (error) {
|
||
logger.error('Azure OpenAI decryption error:', error.message)
|
||
return ''
|
||
}
|
||
}
|
||
|
||
// 创建账户
|
||
async function createAccount(accountData) {
|
||
const accountId = uuidv4()
|
||
const now = new Date().toISOString()
|
||
|
||
const account = {
|
||
id: accountId,
|
||
name: accountData.name,
|
||
description: accountData.description || '',
|
||
accountType: accountData.accountType || 'shared',
|
||
groupId: accountData.groupId || null,
|
||
priority: accountData.priority || 50,
|
||
// Azure OpenAI 特有字段
|
||
azureEndpoint: accountData.azureEndpoint || '',
|
||
apiVersion: accountData.apiVersion || '2024-02-01', // 使用稳定版本
|
||
deploymentName: accountData.deploymentName || 'gpt-4', // 使用默认部署名称
|
||
apiKey: encrypt(accountData.apiKey || ''),
|
||
// 支持的模型
|
||
supportedModels: JSON.stringify(
|
||
accountData.supportedModels || ['gpt-4', 'gpt-4-turbo', 'gpt-35-turbo', 'gpt-35-turbo-16k']
|
||
),
|
||
|
||
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
|
||
// 注意:Azure OpenAI 使用 API Key 认证,没有 OAuth token,因此没有 expiresAt
|
||
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
|
||
|
||
// 状态字段
|
||
isActive: accountData.isActive !== false ? 'true' : 'false',
|
||
status: 'active',
|
||
schedulable: accountData.schedulable !== false ? 'true' : 'false',
|
||
createdAt: now,
|
||
updatedAt: now
|
||
}
|
||
|
||
// 代理配置
|
||
if (accountData.proxy) {
|
||
account.proxy =
|
||
typeof accountData.proxy === 'string' ? accountData.proxy : JSON.stringify(accountData.proxy)
|
||
}
|
||
|
||
const client = redisClient.getClientSafe()
|
||
await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account)
|
||
|
||
// 如果是共享账户,添加到共享账户集合
|
||
if (account.accountType === 'shared') {
|
||
await client.sadd(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId)
|
||
}
|
||
|
||
logger.info(`Created Azure OpenAI account: ${accountId}`)
|
||
return account
|
||
}
|
||
|
||
// 获取账户
|
||
async function getAccount(accountId) {
|
||
const client = redisClient.getClientSafe()
|
||
const accountData = await client.hgetall(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`)
|
||
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
return null
|
||
}
|
||
|
||
// 解密敏感数据(仅用于内部处理,不返回给前端)
|
||
if (accountData.apiKey) {
|
||
accountData.apiKey = decrypt(accountData.apiKey)
|
||
}
|
||
|
||
// 解析代理配置
|
||
if (accountData.proxy && typeof accountData.proxy === 'string') {
|
||
try {
|
||
accountData.proxy = JSON.parse(accountData.proxy)
|
||
} catch (e) {
|
||
accountData.proxy = null
|
||
}
|
||
}
|
||
|
||
// 解析支持的模型
|
||
if (accountData.supportedModels && typeof accountData.supportedModels === 'string') {
|
||
try {
|
||
accountData.supportedModels = JSON.parse(accountData.supportedModels)
|
||
} catch (e) {
|
||
accountData.supportedModels = ['gpt-4', 'gpt-35-turbo']
|
||
}
|
||
}
|
||
|
||
return accountData
|
||
}
|
||
|
||
// 更新账户
|
||
async function updateAccount(accountId, updates) {
|
||
const existingAccount = await getAccount(accountId)
|
||
if (!existingAccount) {
|
||
throw new Error('Account not found')
|
||
}
|
||
|
||
updates.updatedAt = new Date().toISOString()
|
||
|
||
// 加密敏感数据
|
||
if (updates.apiKey) {
|
||
updates.apiKey = encrypt(updates.apiKey)
|
||
}
|
||
|
||
// 处理代理配置
|
||
if (updates.proxy) {
|
||
updates.proxy =
|
||
typeof updates.proxy === 'string' ? updates.proxy : JSON.stringify(updates.proxy)
|
||
}
|
||
|
||
// 处理支持的模型
|
||
if (updates.supportedModels) {
|
||
updates.supportedModels =
|
||
typeof updates.supportedModels === 'string'
|
||
? updates.supportedModels
|
||
: JSON.stringify(updates.supportedModels)
|
||
}
|
||
|
||
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
||
// Azure OpenAI 使用 API Key,没有 token 刷新逻辑,不会覆盖此字段
|
||
if (updates.subscriptionExpiresAt !== undefined) {
|
||
// 直接保存,不做任何调整
|
||
}
|
||
|
||
// 更新账户类型时处理共享账户集合
|
||
const client = redisClient.getClientSafe()
|
||
if (updates.accountType && updates.accountType !== existingAccount.accountType) {
|
||
if (updates.accountType === 'shared') {
|
||
await client.sadd(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId)
|
||
} else {
|
||
await client.srem(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId)
|
||
}
|
||
}
|
||
|
||
await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, updates)
|
||
|
||
logger.info(`Updated Azure OpenAI account: ${accountId}`)
|
||
|
||
// 合并更新后的账户数据
|
||
const updatedAccount = { ...existingAccount, ...updates }
|
||
|
||
// 返回时解析代理配置
|
||
if (updatedAccount.proxy && typeof updatedAccount.proxy === 'string') {
|
||
try {
|
||
updatedAccount.proxy = JSON.parse(updatedAccount.proxy)
|
||
} catch (e) {
|
||
updatedAccount.proxy = null
|
||
}
|
||
}
|
||
|
||
return updatedAccount
|
||
}
|
||
|
||
// 删除账户
|
||
async function deleteAccount(accountId) {
|
||
// 首先从所有分组中移除此账户
|
||
const accountGroupService = require('./accountGroupService')
|
||
await accountGroupService.removeAccountFromAllGroups(accountId)
|
||
|
||
const client = redisClient.getClientSafe()
|
||
const accountKey = `${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`
|
||
|
||
// 从Redis中删除账户数据
|
||
await client.del(accountKey)
|
||
|
||
// 从共享账户集合中移除
|
||
await client.srem(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId)
|
||
|
||
logger.info(`Deleted Azure OpenAI account: ${accountId}`)
|
||
return true
|
||
}
|
||
|
||
// 获取所有账户
|
||
async function getAllAccounts() {
|
||
const client = redisClient.getClientSafe()
|
||
const keys = await client.keys(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}*`)
|
||
|
||
if (!keys || keys.length === 0) {
|
||
return []
|
||
}
|
||
|
||
const accounts = []
|
||
for (const key of keys) {
|
||
const accountData = await client.hgetall(key)
|
||
if (accountData && Object.keys(accountData).length > 0) {
|
||
// 不返回敏感数据给前端
|
||
delete accountData.apiKey
|
||
|
||
// 解析代理配置
|
||
if (accountData.proxy && typeof accountData.proxy === 'string') {
|
||
try {
|
||
accountData.proxy = JSON.parse(accountData.proxy)
|
||
} catch (e) {
|
||
accountData.proxy = null
|
||
}
|
||
}
|
||
|
||
// 解析支持的模型
|
||
if (accountData.supportedModels && typeof accountData.supportedModels === 'string') {
|
||
try {
|
||
accountData.supportedModels = JSON.parse(accountData.supportedModels)
|
||
} catch (e) {
|
||
accountData.supportedModels = ['gpt-4', 'gpt-35-turbo']
|
||
}
|
||
}
|
||
|
||
accounts.push({
|
||
...accountData,
|
||
isActive: accountData.isActive === 'true',
|
||
schedulable: accountData.schedulable !== 'false',
|
||
|
||
// ✅ 前端显示订阅过期时间(业务字段)
|
||
expiresAt: accountData.subscriptionExpiresAt || null,
|
||
platform: 'azure-openai'
|
||
})
|
||
}
|
||
}
|
||
|
||
return accounts
|
||
}
|
||
|
||
// 获取共享账户
|
||
async function getSharedAccounts() {
|
||
const client = redisClient.getClientSafe()
|
||
const accountIds = await client.smembers(SHARED_AZURE_OPENAI_ACCOUNTS_KEY)
|
||
|
||
if (!accountIds || accountIds.length === 0) {
|
||
return []
|
||
}
|
||
|
||
const accounts = []
|
||
for (const accountId of accountIds) {
|
||
const account = await getAccount(accountId)
|
||
if (account && account.isActive === 'true') {
|
||
accounts.push(account)
|
||
}
|
||
}
|
||
|
||
return accounts
|
||
}
|
||
|
||
/**
|
||
* 检查账户订阅是否过期
|
||
* @param {Object} account - 账户对象
|
||
* @returns {boolean} - true: 已过期, false: 未过期
|
||
*/
|
||
function isSubscriptionExpired(account) {
|
||
if (!account.subscriptionExpiresAt) {
|
||
return false // 未设置视为永不过期
|
||
}
|
||
const expiryDate = new Date(account.subscriptionExpiresAt)
|
||
return expiryDate <= new Date()
|
||
}
|
||
|
||
// 选择可用账户
|
||
async function selectAvailableAccount(sessionId = null) {
|
||
// 如果有会话ID,尝试获取之前分配的账户
|
||
if (sessionId) {
|
||
const client = redisClient.getClientSafe()
|
||
const mappingKey = `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionId}`
|
||
const accountId = await client.get(mappingKey)
|
||
|
||
if (accountId) {
|
||
const account = await getAccount(accountId)
|
||
if (account && account.isActive === 'true' && account.schedulable === 'true') {
|
||
logger.debug(`Reusing Azure OpenAI account ${accountId} for session ${sessionId}`)
|
||
return account
|
||
}
|
||
}
|
||
}
|
||
|
||
// 获取所有共享账户
|
||
const sharedAccounts = await getSharedAccounts()
|
||
|
||
// 过滤出可用的账户
|
||
const availableAccounts = sharedAccounts.filter((acc) => {
|
||
// ✅ 检查账户订阅是否过期
|
||
if (isSubscriptionExpired(acc)) {
|
||
logger.debug(
|
||
`⏰ Skipping expired Azure OpenAI account: ${acc.name}, expired at ${acc.subscriptionExpiresAt}`
|
||
)
|
||
return false
|
||
}
|
||
|
||
return acc.isActive === 'true' && acc.schedulable === 'true'
|
||
})
|
||
|
||
if (availableAccounts.length === 0) {
|
||
throw new Error('No available Azure OpenAI accounts')
|
||
}
|
||
|
||
// 按优先级排序并选择
|
||
availableAccounts.sort((a, b) => (b.priority || 50) - (a.priority || 50))
|
||
const selectedAccount = availableAccounts[0]
|
||
|
||
// 如果有会话ID,保存映射关系
|
||
if (sessionId && selectedAccount) {
|
||
const client = redisClient.getClientSafe()
|
||
const mappingKey = `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionId}`
|
||
await client.setex(mappingKey, 3600, selectedAccount.id) // 1小时过期
|
||
}
|
||
|
||
logger.debug(`Selected Azure OpenAI account: ${selectedAccount.id}`)
|
||
return selectedAccount
|
||
}
|
||
|
||
// 更新账户使用量
|
||
async function updateAccountUsage(accountId, tokens) {
|
||
const client = redisClient.getClientSafe()
|
||
const now = new Date().toISOString()
|
||
|
||
// 使用 HINCRBY 原子操作更新使用量
|
||
await client.hincrby(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, 'totalTokensUsed', tokens)
|
||
await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, 'lastUsedAt', now)
|
||
|
||
logger.debug(`Updated Azure OpenAI account ${accountId} usage: ${tokens} tokens`)
|
||
}
|
||
|
||
// 健康检查单个账户
|
||
async function healthCheckAccount(accountId) {
|
||
try {
|
||
const account = await getAccount(accountId)
|
||
if (!account) {
|
||
return { id: accountId, status: 'error', message: 'Account not found' }
|
||
}
|
||
|
||
// 简单检查配置是否完整
|
||
if (!account.azureEndpoint || !account.apiKey || !account.deploymentName) {
|
||
return {
|
||
id: accountId,
|
||
status: 'error',
|
||
message: 'Incomplete configuration'
|
||
}
|
||
}
|
||
|
||
// 可以在这里添加实际的API调用测试
|
||
// 暂时返回成功状态
|
||
return {
|
||
id: accountId,
|
||
status: 'healthy',
|
||
message: 'Account is configured correctly'
|
||
}
|
||
} catch (error) {
|
||
logger.error(`Health check failed for Azure OpenAI account ${accountId}:`, error)
|
||
return {
|
||
id: accountId,
|
||
status: 'error',
|
||
message: error.message
|
||
}
|
||
}
|
||
}
|
||
|
||
// 批量健康检查
|
||
async function performHealthChecks() {
|
||
const accounts = await getAllAccounts()
|
||
const results = []
|
||
|
||
for (const account of accounts) {
|
||
const result = await healthCheckAccount(account.id)
|
||
results.push(result)
|
||
}
|
||
|
||
return results
|
||
}
|
||
|
||
// 切换账户的可调度状态
|
||
async function toggleSchedulable(accountId) {
|
||
const account = await getAccount(accountId)
|
||
if (!account) {
|
||
throw new Error('Account not found')
|
||
}
|
||
|
||
const newSchedulable = account.schedulable === 'true' ? 'false' : 'true'
|
||
await updateAccount(accountId, { schedulable: newSchedulable })
|
||
|
||
return {
|
||
id: accountId,
|
||
schedulable: newSchedulable === 'true'
|
||
}
|
||
}
|
||
|
||
// 迁移 API Keys 以支持 Azure OpenAI
|
||
async function migrateApiKeysForAzureSupport() {
|
||
const client = redisClient.getClientSafe()
|
||
const apiKeyIds = await client.smembers('api_keys')
|
||
|
||
let migratedCount = 0
|
||
for (const keyId of apiKeyIds) {
|
||
const keyData = await client.hgetall(`api_key:${keyId}`)
|
||
if (keyData && !keyData.azureOpenaiAccountId) {
|
||
// 添加 Azure OpenAI 账户ID字段(初始为空)
|
||
await client.hset(`api_key:${keyId}`, 'azureOpenaiAccountId', '')
|
||
migratedCount++
|
||
}
|
||
}
|
||
|
||
logger.info(`Migrated ${migratedCount} API keys for Azure OpenAI support`)
|
||
return migratedCount
|
||
}
|
||
|
||
module.exports = {
|
||
createAccount,
|
||
getAccount,
|
||
updateAccount,
|
||
deleteAccount,
|
||
getAllAccounts,
|
||
getSharedAccounts,
|
||
selectAvailableAccount,
|
||
updateAccountUsage,
|
||
healthCheckAccount,
|
||
performHealthChecks,
|
||
toggleSchedulable,
|
||
migrateApiKeysForAzureSupport,
|
||
encrypt,
|
||
decrypt
|
||
}
|