mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Merge branch 'main' into new
This commit is contained in:
19
src/app.js
19
src/app.js
@@ -14,6 +14,7 @@ const cacheMonitor = require('./utils/cacheMonitor')
|
|||||||
|
|
||||||
// Import routes
|
// Import routes
|
||||||
const apiRoutes = require('./routes/api')
|
const apiRoutes = require('./routes/api')
|
||||||
|
const unifiedRoutes = require('./routes/unified')
|
||||||
const adminRoutes = require('./routes/admin')
|
const adminRoutes = require('./routes/admin')
|
||||||
const webRoutes = require('./routes/web')
|
const webRoutes = require('./routes/web')
|
||||||
const apiStatsRoutes = require('./routes/apiStats')
|
const apiStatsRoutes = require('./routes/apiStats')
|
||||||
@@ -55,6 +56,11 @@ class Application {
|
|||||||
logger.info('🔄 Initializing pricing service...')
|
logger.info('🔄 Initializing pricing service...')
|
||||||
await pricingService.initialize()
|
await pricingService.initialize()
|
||||||
|
|
||||||
|
// 📋 初始化模型服务
|
||||||
|
logger.info('🔄 Initializing model service...')
|
||||||
|
const modelService = require('./services/modelService')
|
||||||
|
await modelService.initialize()
|
||||||
|
|
||||||
// 📊 初始化缓存监控
|
// 📊 初始化缓存监控
|
||||||
await this.initializeCacheMonitoring()
|
await this.initializeCacheMonitoring()
|
||||||
|
|
||||||
@@ -251,6 +257,7 @@ class Application {
|
|||||||
|
|
||||||
// 🛣️ 路由
|
// 🛣️ 路由
|
||||||
this.app.use('/api', apiRoutes)
|
this.app.use('/api', apiRoutes)
|
||||||
|
this.app.use('/api', unifiedRoutes) // 统一智能路由(支持 /v1/chat/completions 等)
|
||||||
this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同
|
this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同
|
||||||
this.app.use('/admin', adminRoutes)
|
this.app.use('/admin', adminRoutes)
|
||||||
this.app.use('/users', userRoutes)
|
this.app.use('/users', userRoutes)
|
||||||
@@ -262,7 +269,8 @@ class Application {
|
|||||||
this.app.use('/gemini', geminiRoutes) // 保留原有路径以保持向后兼容
|
this.app.use('/gemini', geminiRoutes) // 保留原有路径以保持向后兼容
|
||||||
this.app.use('/openai/gemini', openaiGeminiRoutes)
|
this.app.use('/openai/gemini', openaiGeminiRoutes)
|
||||||
this.app.use('/openai/claude', openaiClaudeRoutes)
|
this.app.use('/openai/claude', openaiClaudeRoutes)
|
||||||
this.app.use('/openai', openaiRoutes)
|
this.app.use('/openai', unifiedRoutes) // 复用统一智能路由,支持 /openai/v1/chat/completions
|
||||||
|
this.app.use('/openai', openaiRoutes) // Codex API 路由(/openai/responses, /openai/v1/responses)
|
||||||
// Droid 路由:支持多种 Factory.ai 端点
|
// Droid 路由:支持多种 Factory.ai 端点
|
||||||
this.app.use('/droid', droidRoutes) // Droid (Factory.ai) API 转发
|
this.app.use('/droid', droidRoutes) // Droid (Factory.ai) API 转发
|
||||||
this.app.use('/azure', azureOpenaiRoutes)
|
this.app.use('/azure', azureOpenaiRoutes)
|
||||||
@@ -630,6 +638,15 @@ class Application {
|
|||||||
logger.error('❌ Error cleaning up pricing service:', error)
|
logger.error('❌ Error cleaning up pricing service:', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清理 model service 的文件监听器
|
||||||
|
try {
|
||||||
|
const modelService = require('./services/modelService')
|
||||||
|
modelService.cleanup()
|
||||||
|
logger.info('📋 Model service cleaned up')
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error cleaning up model service:', error)
|
||||||
|
}
|
||||||
|
|
||||||
// 停止限流清理服务
|
// 停止限流清理服务
|
||||||
try {
|
try {
|
||||||
const rateLimitCleanupService = require('./services/rateLimitCleanupService')
|
const rateLimitCleanupService = require('./services/rateLimitCleanupService')
|
||||||
|
|||||||
@@ -61,8 +61,7 @@ const resolveConcurrencyConfig = () => {
|
|||||||
const TOKEN_COUNT_PATHS = new Set([
|
const TOKEN_COUNT_PATHS = new Set([
|
||||||
'/v1/messages/count_tokens',
|
'/v1/messages/count_tokens',
|
||||||
'/api/v1/messages/count_tokens',
|
'/api/v1/messages/count_tokens',
|
||||||
'/claude/v1/messages/count_tokens',
|
'/claude/v1/messages/count_tokens'
|
||||||
'/droid/claude/v1/messages/count_tokens'
|
|
||||||
])
|
])
|
||||||
|
|
||||||
function extractApiKey(req) {
|
function extractApiKey(req) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,6 @@ const logger = require('../utils/logger')
|
|||||||
const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper')
|
const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper')
|
||||||
const sessionHelper = require('../utils/sessionHelper')
|
const sessionHelper = require('../utils/sessionHelper')
|
||||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
|
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
|
||||||
@@ -722,40 +721,23 @@ router.post('/v1/messages', authenticateApiKey, handleMessagesRequest)
|
|||||||
// 🚀 Claude API messages 端点 - /claude/v1/messages (别名)
|
// 🚀 Claude API messages 端点 - /claude/v1/messages (别名)
|
||||||
router.post('/claude/v1/messages', authenticateApiKey, handleMessagesRequest)
|
router.post('/claude/v1/messages', authenticateApiKey, handleMessagesRequest)
|
||||||
|
|
||||||
// 📋 模型列表端点 - Claude Code 客户端需要
|
// 📋 模型列表端点 - 支持 Claude, OpenAI, Gemini
|
||||||
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// 返回支持的模型列表
|
const modelService = require('../services/modelService')
|
||||||
const models = [
|
|
||||||
{
|
// 从 modelService 获取所有支持的模型
|
||||||
id: 'claude-3-5-sonnet-20241022',
|
const models = modelService.getAllModels()
|
||||||
object: 'model',
|
|
||||||
created: 1669599635,
|
// 可选:根据 API Key 的模型限制过滤
|
||||||
owned_by: 'anthropic'
|
let filteredModels = models
|
||||||
},
|
if (req.apiKey.enableModelRestriction && req.apiKey.restrictedModels?.length > 0) {
|
||||||
{
|
filteredModels = models.filter((model) => req.apiKey.restrictedModels.includes(model.id))
|
||||||
id: 'claude-3-5-haiku-20241022',
|
}
|
||||||
object: 'model',
|
|
||||||
created: 1669599635,
|
|
||||||
owned_by: 'anthropic'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'claude-3-opus-20240229',
|
|
||||||
object: 'model',
|
|
||||||
created: 1669599635,
|
|
||||||
owned_by: 'anthropic'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'claude-sonnet-4-20250514',
|
|
||||||
object: 'model',
|
|
||||||
created: 1669599635,
|
|
||||||
owned_by: 'anthropic'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
object: 'list',
|
object: 'list',
|
||||||
data: models
|
data: filteredModels
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Models list error:', error)
|
logger.error('❌ Models list error:', error)
|
||||||
|
|||||||
@@ -60,49 +60,6 @@ router.post('/claude/v1/messages', authenticateApiKey, async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/claude/v1/messages/count_tokens', authenticateApiKey, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const requestBody = { ...req.body }
|
|
||||||
if ('stream' in requestBody) {
|
|
||||||
delete requestBody.stream
|
|
||||||
}
|
|
||||||
const sessionHash = sessionHelper.generateSessionHash(requestBody)
|
|
||||||
|
|
||||||
if (!hasDroidPermission(req.apiKey)) {
|
|
||||||
logger.security(
|
|
||||||
`🚫 API Key ${req.apiKey?.id || 'unknown'} 缺少 Droid 权限,拒绝访问 ${req.originalUrl}`
|
|
||||||
)
|
|
||||||
return res.status(403).json({
|
|
||||||
error: 'permission_denied',
|
|
||||||
message: '此 API Key 未启用 Droid 权限'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await droidRelayService.relayRequest(
|
|
||||||
requestBody,
|
|
||||||
req.apiKey,
|
|
||||||
req,
|
|
||||||
res,
|
|
||||||
req.headers,
|
|
||||||
{
|
|
||||||
endpointType: 'anthropic',
|
|
||||||
sessionHash,
|
|
||||||
customPath: '/a/v1/messages/count_tokens',
|
|
||||||
skipUsageRecord: true,
|
|
||||||
disableStreaming: true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
res.status(result.statusCode).set(result.headers).send(result.body)
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Droid Claude count_tokens relay error:', error)
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'internal_server_error',
|
|
||||||
message: error.message
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// OpenAI 端点 - /v1/responses
|
// OpenAI 端点 - /v1/responses
|
||||||
router.post(['/openai/v1/responses', '/openai/responses'], authenticateApiKey, async (req, res) => {
|
router.post(['/openai/v1/responses', '/openai/responses'], authenticateApiKey, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -490,3 +490,4 @@ router.post('/v1/completions', authenticateApiKey, async (req, res) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
module.exports.handleChatCompletion = handleChatCompletion
|
||||||
|
|||||||
@@ -919,3 +919,4 @@ router.get('/key-info', authenticateApiKey, async (req, res) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
module.exports.handleResponses = handleResponses
|
||||||
|
|||||||
225
src/routes/unified.js
Normal file
225
src/routes/unified.js
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const { authenticateApiKey } = require('../middleware/auth')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
const { handleChatCompletion } = require('./openaiClaudeRoutes')
|
||||||
|
const {
|
||||||
|
handleGenerateContent: geminiHandleGenerateContent,
|
||||||
|
handleStreamGenerateContent: geminiHandleStreamGenerateContent
|
||||||
|
} = require('./geminiRoutes')
|
||||||
|
const openaiRoutes = require('./openaiRoutes')
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
// 🔍 根据模型名称检测后端类型
|
||||||
|
function detectBackendFromModel(modelName) {
|
||||||
|
if (!modelName) {
|
||||||
|
return 'claude' // 默认 Claude
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首先尝试使用 modelService 查找模型的 provider
|
||||||
|
try {
|
||||||
|
const modelService = require('../services/modelService')
|
||||||
|
const provider = modelService.getModelProvider(modelName)
|
||||||
|
|
||||||
|
if (provider === 'anthropic') {
|
||||||
|
return 'claude'
|
||||||
|
}
|
||||||
|
if (provider === 'openai') {
|
||||||
|
return 'openai'
|
||||||
|
}
|
||||||
|
if (provider === 'google') {
|
||||||
|
return 'gemini'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`⚠️ Failed to detect backend from modelService: ${error.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级到前缀匹配作为后备方案
|
||||||
|
const model = modelName.toLowerCase()
|
||||||
|
|
||||||
|
// Claude 模型
|
||||||
|
if (model.startsWith('claude-')) {
|
||||||
|
return 'claude'
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI 模型
|
||||||
|
if (
|
||||||
|
model.startsWith('gpt-') ||
|
||||||
|
model.startsWith('o1-') ||
|
||||||
|
model.startsWith('o3-') ||
|
||||||
|
model === 'chatgpt-4o-latest'
|
||||||
|
) {
|
||||||
|
return 'openai'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini 模型
|
||||||
|
if (model.startsWith('gemini-')) {
|
||||||
|
return 'gemini'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认使用 Claude
|
||||||
|
return 'claude'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚀 智能后端路由处理器
|
||||||
|
async function routeToBackend(req, res, requestedModel) {
|
||||||
|
const backend = detectBackendFromModel(requestedModel)
|
||||||
|
|
||||||
|
logger.info(`🔀 Routing request - Model: ${requestedModel}, Backend: ${backend}`)
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
const permissions = req.apiKey.permissions || 'all'
|
||||||
|
|
||||||
|
if (backend === 'claude') {
|
||||||
|
// Claude 后端:通过 OpenAI 兼容层
|
||||||
|
if (permissions !== 'all' && permissions !== 'claude') {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
message: 'This API key does not have permission to access Claude',
|
||||||
|
type: 'permission_denied',
|
||||||
|
code: 'permission_denied'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await handleChatCompletion(req, res, req.apiKey)
|
||||||
|
} else if (backend === 'openai') {
|
||||||
|
// OpenAI 后端
|
||||||
|
if (permissions !== 'all' && permissions !== 'openai') {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
message: 'This API key does not have permission to access OpenAI',
|
||||||
|
type: 'permission_denied',
|
||||||
|
code: 'permission_denied'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return await openaiRoutes.handleResponses(req, res)
|
||||||
|
} else if (backend === 'gemini') {
|
||||||
|
// Gemini 后端
|
||||||
|
if (permissions !== 'all' && permissions !== 'gemini') {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
message: 'This API key does not have permission to access Gemini',
|
||||||
|
type: 'permission_denied',
|
||||||
|
code: 'permission_denied'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为 Gemini 格式
|
||||||
|
const geminiRequest = {
|
||||||
|
model: requestedModel,
|
||||||
|
messages: req.body.messages,
|
||||||
|
temperature: req.body.temperature || 0.7,
|
||||||
|
max_tokens: req.body.max_tokens || 4096,
|
||||||
|
stream: req.body.stream || false
|
||||||
|
}
|
||||||
|
|
||||||
|
req.body = geminiRequest
|
||||||
|
|
||||||
|
if (geminiRequest.stream) {
|
||||||
|
return await geminiHandleStreamGenerateContent(req, res)
|
||||||
|
} else {
|
||||||
|
return await geminiHandleGenerateContent(req, res)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: `Unsupported backend: ${backend}`,
|
||||||
|
type: 'server_error',
|
||||||
|
code: 'unsupported_backend'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔄 OpenAI 兼容的 chat/completions 端点(智能后端路由)
|
||||||
|
router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// 验证必需参数
|
||||||
|
if (!req.body.messages || !Array.isArray(req.body.messages) || req.body.messages.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
message: 'Messages array is required and cannot be empty',
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
code: 'invalid_request'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedModel = req.body.model || 'claude-3-5-sonnet-20241022'
|
||||||
|
req.body.model = requestedModel // 确保模型已设置
|
||||||
|
|
||||||
|
// 使用统一的后端路由处理器
|
||||||
|
await routeToBackend(req, res, requestedModel)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ OpenAI chat/completions error:', error)
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Internal server error',
|
||||||
|
type: 'server_error',
|
||||||
|
code: 'internal_error'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 🔄 OpenAI 兼容的 completions 端点(传统格式,智能后端路由)
|
||||||
|
router.post('/v1/completions', authenticateApiKey, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// 验证必需参数
|
||||||
|
if (!req.body.prompt) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
message: 'Prompt is required',
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
code: 'invalid_request'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将传统 completions 格式转换为 chat 格式
|
||||||
|
const originalBody = req.body
|
||||||
|
const requestedModel = originalBody.model || 'claude-3-5-sonnet-20241022'
|
||||||
|
|
||||||
|
req.body = {
|
||||||
|
model: requestedModel,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: originalBody.prompt
|
||||||
|
}
|
||||||
|
],
|
||||||
|
max_tokens: originalBody.max_tokens,
|
||||||
|
temperature: originalBody.temperature,
|
||||||
|
top_p: originalBody.top_p,
|
||||||
|
stream: originalBody.stream,
|
||||||
|
stop: originalBody.stop,
|
||||||
|
n: originalBody.n || 1,
|
||||||
|
presence_penalty: originalBody.presence_penalty,
|
||||||
|
frequency_penalty: originalBody.frequency_penalty,
|
||||||
|
logit_bias: originalBody.logit_bias,
|
||||||
|
user: originalBody.user
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用统一的后端路由处理器
|
||||||
|
await routeToBackend(req, res, requestedModel)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ OpenAI completions error:', error)
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Failed to process completion request',
|
||||||
|
type: 'server_error',
|
||||||
|
code: 'internal_error'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
|
module.exports.detectBackendFromModel = detectBackendFromModel
|
||||||
|
module.exports.routeToBackend = routeToBackend
|
||||||
@@ -65,19 +65,6 @@ const AZURE_OPENAI_ACCOUNT_KEY_PREFIX = 'azure_openai:account:'
|
|||||||
const SHARED_AZURE_OPENAI_ACCOUNTS_KEY = 'shared_azure_openai_accounts'
|
const SHARED_AZURE_OPENAI_ACCOUNTS_KEY = 'shared_azure_openai_accounts'
|
||||||
const ACCOUNT_SESSION_MAPPING_PREFIX = 'azure_openai_session_account_mapping:'
|
const ACCOUNT_SESSION_MAPPING_PREFIX = 'azure_openai_session_account_mapping:'
|
||||||
|
|
||||||
function normalizeSubscriptionExpiresAt(value) {
|
|
||||||
if (value === undefined || value === null || value === '') {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = value instanceof Date ? value : new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return date.toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加密函数
|
// 加密函数
|
||||||
function encrypt(text) {
|
function encrypt(text) {
|
||||||
if (!text) {
|
if (!text) {
|
||||||
@@ -142,11 +129,15 @@ async function createAccount(accountData) {
|
|||||||
supportedModels: JSON.stringify(
|
supportedModels: JSON.stringify(
|
||||||
accountData.supportedModels || ['gpt-4', 'gpt-4-turbo', 'gpt-35-turbo', 'gpt-35-turbo-16k']
|
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',
|
isActive: accountData.isActive !== false ? 'true' : 'false',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
schedulable: accountData.schedulable !== false ? 'true' : 'false',
|
schedulable: accountData.schedulable !== false ? 'true' : 'false',
|
||||||
subscriptionExpiresAt: normalizeSubscriptionExpiresAt(accountData.subscriptionExpiresAt || ''),
|
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
}
|
}
|
||||||
@@ -166,10 +157,7 @@ async function createAccount(accountData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Created Azure OpenAI account: ${accountId}`)
|
logger.info(`Created Azure OpenAI account: ${accountId}`)
|
||||||
return {
|
return account
|
||||||
...account,
|
|
||||||
subscriptionExpiresAt: account.subscriptionExpiresAt || null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取账户
|
// 获取账户
|
||||||
@@ -204,11 +192,6 @@ async function getAccount(accountId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
accountData.subscriptionExpiresAt =
|
|
||||||
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== ''
|
|
||||||
? accountData.subscriptionExpiresAt
|
|
||||||
: null
|
|
||||||
|
|
||||||
return accountData
|
return accountData
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,11 +223,10 @@ async function updateAccount(accountId, updates) {
|
|||||||
: JSON.stringify(updates.supportedModels)
|
: JSON.stringify(updates.supportedModels)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) {
|
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
||||||
updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.subscriptionExpiresAt)
|
// Azure OpenAI 使用 API Key,没有 token 刷新逻辑,不会覆盖此字段
|
||||||
} else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt')) {
|
if (updates.subscriptionExpiresAt !== undefined) {
|
||||||
updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.expiresAt)
|
// 直接保存,不做任何调整
|
||||||
delete updates.expiresAt
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新账户类型时处理共享账户集合
|
// 更新账户类型时处理共享账户集合
|
||||||
@@ -273,10 +255,6 @@ async function updateAccount(accountId, updates) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!updatedAccount.subscriptionExpiresAt) {
|
|
||||||
updatedAccount.subscriptionExpiresAt = null
|
|
||||||
}
|
|
||||||
|
|
||||||
return updatedAccount
|
return updatedAccount
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,7 +315,10 @@ async function getAllAccounts() {
|
|||||||
...accountData,
|
...accountData,
|
||||||
isActive: accountData.isActive === 'true',
|
isActive: accountData.isActive === 'true',
|
||||||
schedulable: accountData.schedulable !== 'false',
|
schedulable: accountData.schedulable !== 'false',
|
||||||
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null
|
|
||||||
|
// ✅ 前端显示订阅过期时间(业务字段)
|
||||||
|
expiresAt: accountData.subscriptionExpiresAt || null,
|
||||||
|
platform: 'azure-openai'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -365,6 +346,19 @@ async function getSharedAccounts() {
|
|||||||
return accounts
|
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) {
|
async function selectAvailableAccount(sessionId = null) {
|
||||||
// 如果有会话ID,尝试获取之前分配的账户
|
// 如果有会话ID,尝试获取之前分配的账户
|
||||||
@@ -386,9 +380,17 @@ async function selectAvailableAccount(sessionId = null) {
|
|||||||
const sharedAccounts = await getSharedAccounts()
|
const sharedAccounts = await getSharedAccounts()
|
||||||
|
|
||||||
// 过滤出可用的账户
|
// 过滤出可用的账户
|
||||||
const availableAccounts = sharedAccounts.filter(
|
const availableAccounts = sharedAccounts.filter((acc) => {
|
||||||
(acc) => acc.isActive === 'true' && acc.schedulable === 'true'
|
// ✅ 检查账户订阅是否过期
|
||||||
)
|
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) {
|
if (availableAccounts.length === 0) {
|
||||||
throw new Error('No available Azure OpenAI accounts')
|
throw new Error('No available Azure OpenAI accounts')
|
||||||
|
|||||||
@@ -6,19 +6,6 @@ const config = require('../../config/config')
|
|||||||
const bedrockRelayService = require('./bedrockRelayService')
|
const bedrockRelayService = require('./bedrockRelayService')
|
||||||
const LRUCache = require('../utils/lruCache')
|
const LRUCache = require('../utils/lruCache')
|
||||||
|
|
||||||
function normalizeSubscriptionExpiresAt(value) {
|
|
||||||
if (value === undefined || value === null || value === '') {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = value instanceof Date ? value : new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return date.toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
class BedrockAccountService {
|
class BedrockAccountService {
|
||||||
constructor() {
|
constructor() {
|
||||||
// 加密相关常量
|
// 加密相关常量
|
||||||
@@ -53,8 +40,7 @@ class BedrockAccountService {
|
|||||||
accountType = 'shared', // 'dedicated' or 'shared'
|
accountType = 'shared', // 'dedicated' or 'shared'
|
||||||
priority = 50, // 调度优先级 (1-100,数字越小优先级越高)
|
priority = 50, // 调度优先级 (1-100,数字越小优先级越高)
|
||||||
schedulable = true, // 是否可被调度
|
schedulable = true, // 是否可被调度
|
||||||
credentialType = 'default', // 'default', 'access_key', 'bearer_token'
|
credentialType = 'default' // 'default', 'access_key', 'bearer_token'
|
||||||
subscriptionExpiresAt = null
|
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const accountId = uuidv4()
|
const accountId = uuidv4()
|
||||||
@@ -70,7 +56,11 @@ class BedrockAccountService {
|
|||||||
priority,
|
priority,
|
||||||
schedulable,
|
schedulable,
|
||||||
credentialType,
|
credentialType,
|
||||||
subscriptionExpiresAt: normalizeSubscriptionExpiresAt(subscriptionExpiresAt),
|
|
||||||
|
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
|
||||||
|
// 注意:Bedrock 使用 AWS 凭证,没有 OAuth token,因此没有 expiresAt
|
||||||
|
subscriptionExpiresAt: options.subscriptionExpiresAt || null,
|
||||||
|
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
type: 'bedrock' // 标识这是Bedrock账户
|
type: 'bedrock' // 标识这是Bedrock账户
|
||||||
@@ -99,7 +89,6 @@ class BedrockAccountService {
|
|||||||
priority,
|
priority,
|
||||||
schedulable,
|
schedulable,
|
||||||
credentialType,
|
credentialType,
|
||||||
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
|
|
||||||
createdAt: accountData.createdAt,
|
createdAt: accountData.createdAt,
|
||||||
type: 'bedrock'
|
type: 'bedrock'
|
||||||
}
|
}
|
||||||
@@ -122,11 +111,6 @@ class BedrockAccountService {
|
|||||||
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
||||||
}
|
}
|
||||||
|
|
||||||
account.subscriptionExpiresAt =
|
|
||||||
account.subscriptionExpiresAt && account.subscriptionExpiresAt !== ''
|
|
||||||
? account.subscriptionExpiresAt
|
|
||||||
: null
|
|
||||||
|
|
||||||
logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`)
|
logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -163,12 +147,15 @@ class BedrockAccountService {
|
|||||||
priority: account.priority,
|
priority: account.priority,
|
||||||
schedulable: account.schedulable,
|
schedulable: account.schedulable,
|
||||||
credentialType: account.credentialType,
|
credentialType: account.credentialType,
|
||||||
|
|
||||||
|
// ✅ 前端显示订阅过期时间(业务字段)
|
||||||
|
expiresAt: account.subscriptionExpiresAt || null,
|
||||||
|
|
||||||
createdAt: account.createdAt,
|
createdAt: account.createdAt,
|
||||||
updatedAt: account.updatedAt,
|
updatedAt: account.updatedAt,
|
||||||
type: 'bedrock',
|
type: 'bedrock',
|
||||||
hasCredentials: !!account.awsCredentials,
|
platform: 'bedrock',
|
||||||
expiresAt: account.expiresAt || null,
|
hasCredentials: !!account.awsCredentials
|
||||||
subscriptionExpiresAt: account.subscriptionExpiresAt || null
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,14 +221,6 @@ class BedrockAccountService {
|
|||||||
account.credentialType = updates.credentialType
|
account.credentialType = updates.credentialType
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) {
|
|
||||||
account.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(
|
|
||||||
updates.subscriptionExpiresAt
|
|
||||||
)
|
|
||||||
} else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt')) {
|
|
||||||
account.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.expiresAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新AWS凭证
|
// 更新AWS凭证
|
||||||
if (updates.awsCredentials !== undefined) {
|
if (updates.awsCredentials !== undefined) {
|
||||||
if (updates.awsCredentials) {
|
if (updates.awsCredentials) {
|
||||||
@@ -256,6 +235,12 @@ class BedrockAccountService {
|
|||||||
logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`)
|
logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
||||||
|
// Bedrock 没有 token 刷新逻辑,不会覆盖此字段
|
||||||
|
if (updates.subscriptionExpiresAt !== undefined) {
|
||||||
|
account.subscriptionExpiresAt = updates.subscriptionExpiresAt
|
||||||
|
}
|
||||||
|
|
||||||
account.updatedAt = new Date().toISOString()
|
account.updatedAt = new Date().toISOString()
|
||||||
|
|
||||||
await client.set(`bedrock_account:${accountId}`, JSON.stringify(account))
|
await client.set(`bedrock_account:${accountId}`, JSON.stringify(account))
|
||||||
@@ -276,9 +261,7 @@ class BedrockAccountService {
|
|||||||
schedulable: account.schedulable,
|
schedulable: account.schedulable,
|
||||||
credentialType: account.credentialType,
|
credentialType: account.credentialType,
|
||||||
updatedAt: account.updatedAt,
|
updatedAt: account.updatedAt,
|
||||||
type: 'bedrock',
|
type: 'bedrock'
|
||||||
expiresAt: account.expiresAt || null,
|
|
||||||
subscriptionExpiresAt: account.subscriptionExpiresAt || null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -315,9 +298,17 @@ class BedrockAccountService {
|
|||||||
return { success: false, error: 'Failed to get accounts' }
|
return { success: false, error: 'Failed to get accounts' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const availableAccounts = accountsResult.data.filter(
|
const availableAccounts = accountsResult.data.filter((account) => {
|
||||||
(account) => account.isActive && account.schedulable
|
// ✅ 检查账户订阅是否过期
|
||||||
)
|
if (this.isSubscriptionExpired(account)) {
|
||||||
|
logger.debug(
|
||||||
|
`⏰ Skipping expired Bedrock account: ${account.name}, expired at ${account.subscriptionExpiresAt || account.expiresAt}`
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return account.isActive && account.schedulable
|
||||||
|
})
|
||||||
|
|
||||||
if (availableAccounts.length === 0) {
|
if (availableAccounts.length === 0) {
|
||||||
return { success: false, error: 'No available Bedrock accounts' }
|
return { success: false, error: 'No available Bedrock accounts' }
|
||||||
@@ -385,6 +376,19 @@ class BedrockAccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查账户订阅是否过期
|
||||||
|
* @param {Object} account - 账户对象
|
||||||
|
* @returns {boolean} - true: 已过期, false: 未过期
|
||||||
|
*/
|
||||||
|
isSubscriptionExpired(account) {
|
||||||
|
if (!account.subscriptionExpiresAt) {
|
||||||
|
return false // 未设置视为永不过期
|
||||||
|
}
|
||||||
|
const expiryDate = new Date(account.subscriptionExpiresAt)
|
||||||
|
return expiryDate <= new Date()
|
||||||
|
}
|
||||||
|
|
||||||
// 🔑 生成加密密钥(缓存优化)
|
// 🔑 生成加密密钥(缓存优化)
|
||||||
_generateEncryptionKey() {
|
_generateEncryptionKey() {
|
||||||
if (!this._encryptionKeyCache) {
|
if (!this._encryptionKeyCache) {
|
||||||
|
|||||||
@@ -6,19 +6,6 @@ const logger = require('../utils/logger')
|
|||||||
const config = require('../../config/config')
|
const config = require('../../config/config')
|
||||||
const LRUCache = require('../utils/lruCache')
|
const LRUCache = require('../utils/lruCache')
|
||||||
|
|
||||||
function normalizeSubscriptionExpiresAt(value) {
|
|
||||||
if (value === undefined || value === null || value === '') {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = value instanceof Date ? value : new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return date.toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
class CcrAccountService {
|
class CcrAccountService {
|
||||||
constructor() {
|
constructor() {
|
||||||
// 加密相关常量
|
// 加密相关常量
|
||||||
@@ -62,8 +49,7 @@ class CcrAccountService {
|
|||||||
accountType = 'shared', // 'dedicated' or 'shared'
|
accountType = 'shared', // 'dedicated' or 'shared'
|
||||||
schedulable = true, // 是否可被调度
|
schedulable = true, // 是否可被调度
|
||||||
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
||||||
quotaResetTime = '00:00', // 额度重置时间(HH:mm格式)
|
quotaResetTime = '00:00' // 额度重置时间(HH:mm格式)
|
||||||
subscriptionExpiresAt = null
|
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
// 验证必填字段
|
// 验证必填字段
|
||||||
@@ -90,6 +76,11 @@ class CcrAccountService {
|
|||||||
proxy: proxy ? JSON.stringify(proxy) : '',
|
proxy: proxy ? JSON.stringify(proxy) : '',
|
||||||
isActive: isActive.toString(),
|
isActive: isActive.toString(),
|
||||||
accountType,
|
accountType,
|
||||||
|
|
||||||
|
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
|
||||||
|
// 注意:CCR 使用 API Key 认证,没有 OAuth token,因此没有 expiresAt
|
||||||
|
subscriptionExpiresAt: options.subscriptionExpiresAt || null,
|
||||||
|
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
lastUsedAt: '',
|
lastUsedAt: '',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
@@ -105,8 +96,7 @@ class CcrAccountService {
|
|||||||
// 使用与统计一致的时区日期,避免边界问题
|
// 使用与统计一致的时区日期,避免边界问题
|
||||||
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
|
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
|
||||||
quotaResetTime, // 额度重置时间
|
quotaResetTime, // 额度重置时间
|
||||||
quotaStoppedAt: '', // 因额度停用的时间
|
quotaStoppedAt: '' // 因额度停用的时间
|
||||||
subscriptionExpiresAt: normalizeSubscriptionExpiresAt(subscriptionExpiresAt)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
@@ -142,8 +132,7 @@ class CcrAccountService {
|
|||||||
dailyUsage: 0,
|
dailyUsage: 0,
|
||||||
lastResetDate: accountData.lastResetDate,
|
lastResetDate: accountData.lastResetDate,
|
||||||
quotaResetTime,
|
quotaResetTime,
|
||||||
quotaStoppedAt: null,
|
quotaStoppedAt: null
|
||||||
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,14 +170,16 @@ class CcrAccountService {
|
|||||||
errorMessage: accountData.errorMessage,
|
errorMessage: accountData.errorMessage,
|
||||||
rateLimitInfo,
|
rateLimitInfo,
|
||||||
schedulable: accountData.schedulable !== 'false', // 默认为true,只有明确设置为false才不可调度
|
schedulable: accountData.schedulable !== 'false', // 默认为true,只有明确设置为false才不可调度
|
||||||
|
|
||||||
|
// ✅ 前端显示订阅过期时间(业务字段)
|
||||||
|
expiresAt: accountData.subscriptionExpiresAt || null,
|
||||||
|
|
||||||
// 额度管理相关
|
// 额度管理相关
|
||||||
dailyQuota: parseFloat(accountData.dailyQuota || '0'),
|
dailyQuota: parseFloat(accountData.dailyQuota || '0'),
|
||||||
dailyUsage: parseFloat(accountData.dailyUsage || '0'),
|
dailyUsage: parseFloat(accountData.dailyUsage || '0'),
|
||||||
lastResetDate: accountData.lastResetDate || '',
|
lastResetDate: accountData.lastResetDate || '',
|
||||||
quotaResetTime: accountData.quotaResetTime || '00:00',
|
quotaResetTime: accountData.quotaResetTime || '00:00',
|
||||||
quotaStoppedAt: accountData.quotaStoppedAt || null,
|
quotaStoppedAt: accountData.quotaStoppedAt || null
|
||||||
expiresAt: accountData.expiresAt || null,
|
|
||||||
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,11 +234,6 @@ class CcrAccountService {
|
|||||||
`[DEBUG] Final CCR account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}`
|
`[DEBUG] Final CCR account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}`
|
||||||
)
|
)
|
||||||
|
|
||||||
accountData.subscriptionExpiresAt =
|
|
||||||
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== ''
|
|
||||||
? accountData.subscriptionExpiresAt
|
|
||||||
: null
|
|
||||||
|
|
||||||
return accountData
|
return accountData
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,12 +297,10 @@ class CcrAccountService {
|
|||||||
updatedData.quotaResetTime = updates.quotaResetTime
|
updatedData.quotaResetTime = updates.quotaResetTime
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) {
|
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
||||||
updatedData.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(
|
// CCR 使用 API Key,没有 token 刷新逻辑,不会覆盖此字段
|
||||||
updates.subscriptionExpiresAt
|
if (updates.subscriptionExpiresAt !== undefined) {
|
||||||
)
|
updatedData.subscriptionExpiresAt = updates.subscriptionExpiresAt
|
||||||
} else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt')) {
|
|
||||||
updatedData.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.expiresAt)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updatedData)
|
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updatedData)
|
||||||
@@ -929,6 +913,19 @@ class CcrAccountService {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⏰ 检查账户订阅是否过期
|
||||||
|
* @param {Object} account - 账户对象
|
||||||
|
* @returns {boolean} - true: 已过期, false: 未过期
|
||||||
|
*/
|
||||||
|
isSubscriptionExpired(account) {
|
||||||
|
if (!account.subscriptionExpiresAt) {
|
||||||
|
return false // 未设置视为永不过期
|
||||||
|
}
|
||||||
|
const expiryDate = new Date(account.subscriptionExpiresAt)
|
||||||
|
return expiryDate <= new Date()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new CcrAccountService()
|
module.exports = new CcrAccountService()
|
||||||
|
|||||||
@@ -787,13 +787,13 @@ class ClaudeAccountService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查账户是否未过期
|
* 检查账户订阅是否过期
|
||||||
* @param {Object} account - 账户对象
|
* @param {Object} account - 账户对象
|
||||||
* @returns {boolean} - 如果未设置过期时间或未过期返回 true
|
* @returns {boolean} - true: 已过期, false: 未过期
|
||||||
*/
|
*/
|
||||||
isAccountNotExpired(account) {
|
isSubscriptionExpired(account) {
|
||||||
if (!account.subscriptionExpiresAt) {
|
if (!account.subscriptionExpiresAt) {
|
||||||
return true // 未设置过期时间,视为永不过期
|
return false // 未设置过期时间,视为永不过期
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiryDate = new Date(account.subscriptionExpiresAt)
|
const expiryDate = new Date(account.subscriptionExpiresAt)
|
||||||
@@ -803,10 +803,10 @@ class ClaudeAccountService {
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
`⏰ Account ${account.name} (${account.id}) expired at ${account.subscriptionExpiresAt}`
|
`⏰ Account ${account.name} (${account.id}) expired at ${account.subscriptionExpiresAt}`
|
||||||
)
|
)
|
||||||
return false
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🎯 智能选择可用账户(支持sticky会话和模型过滤)
|
// 🎯 智能选择可用账户(支持sticky会话和模型过滤)
|
||||||
@@ -819,7 +819,7 @@ class ClaudeAccountService {
|
|||||||
account.isActive === 'true' &&
|
account.isActive === 'true' &&
|
||||||
account.status !== 'error' &&
|
account.status !== 'error' &&
|
||||||
account.schedulable !== 'false' &&
|
account.schedulable !== 'false' &&
|
||||||
this.isAccountNotExpired(account)
|
!this.isSubscriptionExpired(account)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号
|
// 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号
|
||||||
@@ -915,7 +915,7 @@ class ClaudeAccountService {
|
|||||||
boundAccount.isActive === 'true' &&
|
boundAccount.isActive === 'true' &&
|
||||||
boundAccount.status !== 'error' &&
|
boundAccount.status !== 'error' &&
|
||||||
boundAccount.schedulable !== 'false' &&
|
boundAccount.schedulable !== 'false' &&
|
||||||
this.isAccountNotExpired(boundAccount)
|
!this.isSubscriptionExpired(boundAccount)
|
||||||
) {
|
) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`🎯 Using bound dedicated account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`
|
`🎯 Using bound dedicated account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`
|
||||||
@@ -937,7 +937,7 @@ class ClaudeAccountService {
|
|||||||
account.status !== 'error' &&
|
account.status !== 'error' &&
|
||||||
account.schedulable !== 'false' &&
|
account.schedulable !== 'false' &&
|
||||||
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
|
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
|
||||||
this.isAccountNotExpired(account)
|
!this.isSubscriptionExpired(account)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号
|
// 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号
|
||||||
|
|||||||
@@ -6,19 +6,6 @@ const logger = require('../utils/logger')
|
|||||||
const config = require('../../config/config')
|
const config = require('../../config/config')
|
||||||
const LRUCache = require('../utils/lruCache')
|
const LRUCache = require('../utils/lruCache')
|
||||||
|
|
||||||
function normalizeSubscriptionExpiresAt(value) {
|
|
||||||
if (value === undefined || value === null || value === '') {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = value instanceof Date ? value : new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return date.toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
class ClaudeConsoleAccountService {
|
class ClaudeConsoleAccountService {
|
||||||
constructor() {
|
constructor() {
|
||||||
// 加密相关常量
|
// 加密相关常量
|
||||||
@@ -65,8 +52,7 @@ class ClaudeConsoleAccountService {
|
|||||||
accountType = 'shared', // 'dedicated' or 'shared'
|
accountType = 'shared', // 'dedicated' or 'shared'
|
||||||
schedulable = true, // 是否可被调度
|
schedulable = true, // 是否可被调度
|
||||||
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
||||||
quotaResetTime = '00:00', // 额度重置时间(HH:mm格式)
|
quotaResetTime = '00:00' // 额度重置时间(HH:mm格式)
|
||||||
subscriptionExpiresAt = null
|
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
// 验证必填字段
|
// 验证必填字段
|
||||||
@@ -97,6 +83,11 @@ class ClaudeConsoleAccountService {
|
|||||||
lastUsedAt: '',
|
lastUsedAt: '',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
|
|
||||||
|
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
|
||||||
|
// 注意:Claude Console 没有 OAuth token,因此没有 expiresAt(token过期)
|
||||||
|
subscriptionExpiresAt: options.subscriptionExpiresAt || null,
|
||||||
|
|
||||||
// 限流相关
|
// 限流相关
|
||||||
rateLimitedAt: '',
|
rateLimitedAt: '',
|
||||||
rateLimitStatus: '',
|
rateLimitStatus: '',
|
||||||
@@ -108,8 +99,7 @@ class ClaudeConsoleAccountService {
|
|||||||
// 使用与统计一致的时区日期,避免边界问题
|
// 使用与统计一致的时区日期,避免边界问题
|
||||||
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
|
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
|
||||||
quotaResetTime, // 额度重置时间
|
quotaResetTime, // 额度重置时间
|
||||||
quotaStoppedAt: '', // 因额度停用的时间
|
quotaStoppedAt: '' // 因额度停用的时间
|
||||||
subscriptionExpiresAt: normalizeSubscriptionExpiresAt(subscriptionExpiresAt)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
@@ -145,8 +135,7 @@ class ClaudeConsoleAccountService {
|
|||||||
dailyUsage: 0,
|
dailyUsage: 0,
|
||||||
lastResetDate: accountData.lastResetDate,
|
lastResetDate: accountData.lastResetDate,
|
||||||
quotaResetTime,
|
quotaResetTime,
|
||||||
quotaStoppedAt: null,
|
quotaStoppedAt: null
|
||||||
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,14 +173,16 @@ class ClaudeConsoleAccountService {
|
|||||||
errorMessage: accountData.errorMessage,
|
errorMessage: accountData.errorMessage,
|
||||||
rateLimitInfo,
|
rateLimitInfo,
|
||||||
schedulable: accountData.schedulable !== 'false', // 默认为true,只有明确设置为false才不可调度
|
schedulable: accountData.schedulable !== 'false', // 默认为true,只有明确设置为false才不可调度
|
||||||
|
|
||||||
|
// ✅ 前端显示订阅过期时间(业务字段)
|
||||||
|
expiresAt: accountData.subscriptionExpiresAt || null,
|
||||||
|
|
||||||
// 额度管理相关
|
// 额度管理相关
|
||||||
dailyQuota: parseFloat(accountData.dailyQuota || '0'),
|
dailyQuota: parseFloat(accountData.dailyQuota || '0'),
|
||||||
dailyUsage: parseFloat(accountData.dailyUsage || '0'),
|
dailyUsage: parseFloat(accountData.dailyUsage || '0'),
|
||||||
lastResetDate: accountData.lastResetDate || '',
|
lastResetDate: accountData.lastResetDate || '',
|
||||||
quotaResetTime: accountData.quotaResetTime || '00:00',
|
quotaResetTime: accountData.quotaResetTime || '00:00',
|
||||||
quotaStoppedAt: accountData.quotaStoppedAt || null,
|
quotaStoppedAt: accountData.quotaStoppedAt || null
|
||||||
expiresAt: accountData.expiresAt || null,
|
|
||||||
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,11 +233,6 @@ class ClaudeConsoleAccountService {
|
|||||||
accountData.proxy = JSON.parse(accountData.proxy)
|
accountData.proxy = JSON.parse(accountData.proxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
accountData.subscriptionExpiresAt =
|
|
||||||
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== ''
|
|
||||||
? accountData.subscriptionExpiresAt
|
|
||||||
: null
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[DEBUG] Final account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}`
|
`[DEBUG] Final account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}`
|
||||||
)
|
)
|
||||||
@@ -341,12 +327,10 @@ class ClaudeConsoleAccountService {
|
|||||||
updatedData.quotaStoppedAt = updates.quotaStoppedAt
|
updatedData.quotaStoppedAt = updates.quotaStoppedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) {
|
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
||||||
updatedData.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(
|
// Claude Console 没有 token 刷新逻辑,不会覆盖此字段
|
||||||
updates.subscriptionExpiresAt
|
if (updates.subscriptionExpiresAt !== undefined) {
|
||||||
)
|
updatedData.subscriptionExpiresAt = updates.subscriptionExpiresAt
|
||||||
} else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt')) {
|
|
||||||
updatedData.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.expiresAt)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理账户类型变更
|
// 处理账户类型变更
|
||||||
@@ -1270,6 +1254,19 @@ class ClaudeConsoleAccountService {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⏰ 检查账户订阅是否过期
|
||||||
|
* @param {Object} account - 账户对象
|
||||||
|
* @returns {boolean} - true: 已过期, false: 未过期
|
||||||
|
*/
|
||||||
|
isSubscriptionExpired(account) {
|
||||||
|
if (!account.subscriptionExpiresAt) {
|
||||||
|
return false // 未设置视为永不过期
|
||||||
|
}
|
||||||
|
const expiryDate = new Date(account.subscriptionExpiresAt)
|
||||||
|
return expiryDate <= new Date()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new ClaudeConsoleAccountService()
|
module.exports = new ClaudeConsoleAccountService()
|
||||||
|
|||||||
@@ -794,7 +794,11 @@ class DroidAccountService {
|
|||||||
description,
|
description,
|
||||||
refreshToken: this._encryptSensitiveData(normalizedRefreshToken),
|
refreshToken: this._encryptSensitiveData(normalizedRefreshToken),
|
||||||
accessToken: this._encryptSensitiveData(normalizedAccessToken),
|
accessToken: this._encryptSensitiveData(normalizedAccessToken),
|
||||||
expiresAt: normalizedExpiresAt || '',
|
expiresAt: normalizedExpiresAt || '', // OAuth Token 过期时间(技术字段,自动刷新)
|
||||||
|
|
||||||
|
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
|
||||||
|
subscriptionExpiresAt: options.subscriptionExpiresAt || null,
|
||||||
|
|
||||||
proxy: proxy ? JSON.stringify(proxy) : '',
|
proxy: proxy ? JSON.stringify(proxy) : '',
|
||||||
isActive: isActive.toString(),
|
isActive: isActive.toString(),
|
||||||
accountType,
|
accountType,
|
||||||
@@ -880,6 +884,11 @@ class DroidAccountService {
|
|||||||
accessToken: account.accessToken
|
accessToken: account.accessToken
|
||||||
? maskToken(this._decryptSensitiveData(account.accessToken))
|
? maskToken(this._decryptSensitiveData(account.accessToken))
|
||||||
: '',
|
: '',
|
||||||
|
|
||||||
|
// ✅ 前端显示订阅过期时间(业务字段)
|
||||||
|
expiresAt: account.subscriptionExpiresAt || null,
|
||||||
|
platform: account.platform || 'droid',
|
||||||
|
|
||||||
apiKeyCount: (() => {
|
apiKeyCount: (() => {
|
||||||
const parsedCount = this._parseApiKeyEntries(account.apiKeys).length
|
const parsedCount = this._parseApiKeyEntries(account.apiKeys).length
|
||||||
if (account.apiKeyCount === undefined || account.apiKeyCount === null) {
|
if (account.apiKeyCount === undefined || account.apiKeyCount === null) {
|
||||||
@@ -1020,6 +1029,12 @@ class DroidAccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ 如果通过路由映射更新了 subscriptionExpiresAt,直接保存
|
||||||
|
// subscriptionExpiresAt 是业务字段,与 token 刷新独立
|
||||||
|
if (sanitizedUpdates.subscriptionExpiresAt !== undefined) {
|
||||||
|
// 直接保存,不做任何调整
|
||||||
|
}
|
||||||
|
|
||||||
if (sanitizedUpdates.proxy === undefined) {
|
if (sanitizedUpdates.proxy === undefined) {
|
||||||
sanitizedUpdates.proxy = account.proxy || ''
|
sanitizedUpdates.proxy = account.proxy || ''
|
||||||
}
|
}
|
||||||
@@ -1374,6 +1389,19 @@ class DroidAccountService {
|
|||||||
return hoursSinceRefresh >= this.refreshIntervalHours
|
return hoursSinceRefresh >= this.refreshIntervalHours
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查账户订阅是否过期
|
||||||
|
* @param {Object} account - 账户对象
|
||||||
|
* @returns {boolean} - true: 已过期, false: 未过期
|
||||||
|
*/
|
||||||
|
isSubscriptionExpired(account) {
|
||||||
|
if (!account.subscriptionExpiresAt) {
|
||||||
|
return false // 未设置视为永不过期
|
||||||
|
}
|
||||||
|
const expiryDate = new Date(account.subscriptionExpiresAt)
|
||||||
|
return expiryDate <= new Date()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取有效的 access token(自动刷新)
|
* 获取有效的 access token(自动刷新)
|
||||||
*/
|
*/
|
||||||
@@ -1419,6 +1447,14 @@ class DroidAccountService {
|
|||||||
const isSchedulable = this._isTruthy(account.schedulable)
|
const isSchedulable = this._isTruthy(account.schedulable)
|
||||||
const status = typeof account.status === 'string' ? account.status.toLowerCase() : ''
|
const status = typeof account.status === 'string' ? account.status.toLowerCase() : ''
|
||||||
|
|
||||||
|
// ✅ 检查账户订阅是否过期
|
||||||
|
if (this.isSubscriptionExpired(account)) {
|
||||||
|
logger.debug(
|
||||||
|
`⏰ Skipping expired Droid account: ${account.name}, expired at ${account.subscriptionExpiresAt}`
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!isActive || !isSchedulable || status !== 'active') {
|
if (!isActive || !isSchedulable || status !== 'active') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ const apiKeyService = require('./apiKeyService')
|
|||||||
const redis = require('../models/redis')
|
const redis = require('../models/redis')
|
||||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
|
const runtimeAddon = require('../utils/runtimeAddon')
|
||||||
|
|
||||||
const SYSTEM_PROMPT = 'You are Droid, an AI software engineering agent built by Factory.'
|
const SYSTEM_PROMPT = 'You are Droid, an AI software engineering agent built by Factory.'
|
||||||
|
const RUNTIME_EVENT_FMT_PAYLOAD = 'fmtPayload'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Droid API 转发服务
|
* Droid API 转发服务
|
||||||
@@ -23,7 +25,7 @@ class DroidRelayService {
|
|||||||
openai: '/o/v1/responses'
|
openai: '/o/v1/responses'
|
||||||
}
|
}
|
||||||
|
|
||||||
this.userAgent = 'factory-cli/0.19.4'
|
this.userAgent = 'factory-cli/0.19.12'
|
||||||
this.systemPrompt = SYSTEM_PROMPT
|
this.systemPrompt = SYSTEM_PROMPT
|
||||||
this.API_KEY_STICKY_PREFIX = 'droid_api_key'
|
this.API_KEY_STICKY_PREFIX = 'droid_api_key'
|
||||||
}
|
}
|
||||||
@@ -246,11 +248,34 @@ class DroidRelayService {
|
|||||||
// 处理请求体(注入 system prompt 等)
|
// 处理请求体(注入 system prompt 等)
|
||||||
const streamRequested = !disableStreaming && this._isStreamRequested(normalizedRequestBody)
|
const streamRequested = !disableStreaming && this._isStreamRequested(normalizedRequestBody)
|
||||||
|
|
||||||
const processedBody = this._processRequestBody(normalizedRequestBody, normalizedEndpoint, {
|
let processedBody = this._processRequestBody(normalizedRequestBody, normalizedEndpoint, {
|
||||||
disableStreaming,
|
disableStreaming,
|
||||||
streamRequested
|
streamRequested
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const extensionPayload = {
|
||||||
|
body: processedBody,
|
||||||
|
endpoint: normalizedEndpoint,
|
||||||
|
rawRequest: normalizedRequestBody,
|
||||||
|
originalRequest: requestBody
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionResult = runtimeAddon.emitSync(RUNTIME_EVENT_FMT_PAYLOAD, extensionPayload)
|
||||||
|
const resolvedPayload =
|
||||||
|
extensionResult && typeof extensionResult === 'object' ? extensionResult : extensionPayload
|
||||||
|
|
||||||
|
if (resolvedPayload && typeof resolvedPayload === 'object') {
|
||||||
|
if (resolvedPayload.abortResponse && typeof resolvedPayload.abortResponse === 'object') {
|
||||||
|
return resolvedPayload.abortResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedPayload.body && typeof resolvedPayload.body === 'object') {
|
||||||
|
processedBody = resolvedPayload.body
|
||||||
|
} else if (resolvedPayload !== extensionPayload) {
|
||||||
|
processedBody = resolvedPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 发送请求
|
// 发送请求
|
||||||
const isStreaming = streamRequested
|
const isStreaming = streamRequested
|
||||||
|
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ class DroidScheduler {
|
|||||||
|
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`No available Droid accounts for endpoint ${normalizedEndpoint}${apiKeyData?.droidAccountId ? ' (respecting binding)' : ''}`
|
`No available accounts for endpoint ${normalizedEndpoint}${apiKeyData?.droidAccountId ? ' (respecting binding)' : ''}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,9 +196,7 @@ class DroidScheduler {
|
|||||||
const selected = sorted[0]
|
const selected = sorted[0]
|
||||||
|
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
throw new Error(
|
throw new Error(`No schedulable account available after sorting (${normalizedEndpoint})`)
|
||||||
`No schedulable Droid account available after sorting (${normalizedEndpoint})`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stickyKey && !isDedicatedBinding) {
|
if (stickyKey && !isDedicatedBinding) {
|
||||||
|
|||||||
@@ -42,19 +42,6 @@ function generateEncryptionKey() {
|
|||||||
return _encryptionKeyCache
|
return _encryptionKeyCache
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSubscriptionExpiresAt(value) {
|
|
||||||
if (value === undefined || value === null || value === '') {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = value instanceof Date ? value : new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return date.toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gemini 账户键前缀
|
// Gemini 账户键前缀
|
||||||
const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:'
|
const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:'
|
||||||
const SHARED_GEMINI_ACCOUNTS_KEY = 'shared_gemini_accounts'
|
const SHARED_GEMINI_ACCOUNTS_KEY = 'shared_gemini_accounts'
|
||||||
@@ -346,10 +333,6 @@ async function createAccount(accountData) {
|
|||||||
let refreshToken = ''
|
let refreshToken = ''
|
||||||
let expiresAt = ''
|
let expiresAt = ''
|
||||||
|
|
||||||
const subscriptionExpiresAt = normalizeSubscriptionExpiresAt(
|
|
||||||
accountData.subscriptionExpiresAt || ''
|
|
||||||
)
|
|
||||||
|
|
||||||
if (accountData.geminiOauth || accountData.accessToken) {
|
if (accountData.geminiOauth || accountData.accessToken) {
|
||||||
// 如果提供了完整的 OAuth 数据
|
// 如果提供了完整的 OAuth 数据
|
||||||
if (accountData.geminiOauth) {
|
if (accountData.geminiOauth) {
|
||||||
@@ -401,10 +384,13 @@ async function createAccount(accountData) {
|
|||||||
geminiOauth: geminiOauth ? encrypt(geminiOauth) : '',
|
geminiOauth: geminiOauth ? encrypt(geminiOauth) : '',
|
||||||
accessToken: accessToken ? encrypt(accessToken) : '',
|
accessToken: accessToken ? encrypt(accessToken) : '',
|
||||||
refreshToken: refreshToken ? encrypt(refreshToken) : '',
|
refreshToken: refreshToken ? encrypt(refreshToken) : '',
|
||||||
expiresAt,
|
expiresAt, // OAuth Token 过期时间(技术字段,自动刷新)
|
||||||
// 只有OAuth方式才有scopes,手动添加的没有
|
// 只有OAuth方式才有scopes,手动添加的没有
|
||||||
scopes: accountData.geminiOauth ? accountData.scopes || OAUTH_SCOPES.join(' ') : '',
|
scopes: accountData.geminiOauth ? accountData.scopes || OAUTH_SCOPES.join(' ') : '',
|
||||||
|
|
||||||
|
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
|
||||||
|
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
|
||||||
|
|
||||||
// 代理设置
|
// 代理设置
|
||||||
proxy: accountData.proxy ? JSON.stringify(accountData.proxy) : '',
|
proxy: accountData.proxy ? JSON.stringify(accountData.proxy) : '',
|
||||||
|
|
||||||
@@ -421,8 +407,7 @@ async function createAccount(accountData) {
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
lastUsedAt: '',
|
lastUsedAt: '',
|
||||||
lastRefreshAt: '',
|
lastRefreshAt: ''
|
||||||
subscriptionExpiresAt
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存到 Redis
|
// 保存到 Redis
|
||||||
@@ -446,10 +431,6 @@ async function createAccount(accountData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!returnAccount.subscriptionExpiresAt) {
|
|
||||||
returnAccount.subscriptionExpiresAt = null
|
|
||||||
}
|
|
||||||
|
|
||||||
return returnAccount
|
return returnAccount
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,10 +467,6 @@ async function getAccount(accountId) {
|
|||||||
// 转换 schedulable 字符串为布尔值(与 claudeConsoleAccountService 保持一致)
|
// 转换 schedulable 字符串为布尔值(与 claudeConsoleAccountService 保持一致)
|
||||||
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true,只有明确设置为'false'才为false
|
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true,只有明确设置为'false'才为false
|
||||||
|
|
||||||
if (!accountData.subscriptionExpiresAt) {
|
|
||||||
accountData.subscriptionExpiresAt = null
|
|
||||||
}
|
|
||||||
|
|
||||||
return accountData
|
return accountData
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,10 +480,6 @@ async function updateAccount(accountId, updates) {
|
|||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
updates.updatedAt = now
|
updates.updatedAt = now
|
||||||
|
|
||||||
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) {
|
|
||||||
updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.subscriptionExpiresAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否新增了 refresh token
|
// 检查是否新增了 refresh token
|
||||||
// existingAccount.refreshToken 已经是解密后的值了(从 getAccount 返回)
|
// existingAccount.refreshToken 已经是解密后的值了(从 getAccount 返回)
|
||||||
const oldRefreshToken = existingAccount.refreshToken || ''
|
const oldRefreshToken = existingAccount.refreshToken || ''
|
||||||
@@ -551,15 +524,23 @@ async function updateAccount(accountId, updates) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果新增了 refresh token,更新过期时间为10分钟
|
// ✅ 关键:如果新增了 refresh token,只更新 token 过期时间
|
||||||
|
// 不要覆盖 subscriptionExpiresAt
|
||||||
if (needUpdateExpiry) {
|
if (needUpdateExpiry) {
|
||||||
const newExpiry = new Date(Date.now() + 10 * 60 * 1000).toISOString()
|
const newExpiry = new Date(Date.now() + 10 * 60 * 1000).toISOString()
|
||||||
updates.expiresAt = newExpiry
|
updates.expiresAt = newExpiry // 只更新 OAuth Token 过期时间
|
||||||
|
// ⚠️ 重要:不要修改 subscriptionExpiresAt
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔄 New refresh token added for Gemini account ${accountId}, setting expiry to 10 minutes`
|
`🔄 New refresh token added for Gemini account ${accountId}, setting token expiry to 10 minutes`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ 如果通过路由映射更新了 subscriptionExpiresAt,直接保存
|
||||||
|
// subscriptionExpiresAt 是业务字段,与 token 刷新独立
|
||||||
|
if (updates.subscriptionExpiresAt !== undefined) {
|
||||||
|
// 直接保存,不做任何调整
|
||||||
|
}
|
||||||
|
|
||||||
// 如果通过 geminiOauth 更新,也要检查是否新增了 refresh token
|
// 如果通过 geminiOauth 更新,也要检查是否新增了 refresh token
|
||||||
if (updates.geminiOauth && !oldRefreshToken) {
|
if (updates.geminiOauth && !oldRefreshToken) {
|
||||||
const oauthData =
|
const oauthData =
|
||||||
@@ -616,10 +597,6 @@ async function updateAccount(accountId, updates) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!updatedAccount.subscriptionExpiresAt) {
|
|
||||||
updatedAccount.subscriptionExpiresAt = null
|
|
||||||
}
|
|
||||||
|
|
||||||
return updatedAccount
|
return updatedAccount
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,13 +654,25 @@ async function getAllAccounts() {
|
|||||||
// 转换 schedulable 字符串为布尔值(与 getAccount 保持一致)
|
// 转换 schedulable 字符串为布尔值(与 getAccount 保持一致)
|
||||||
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true,只有明确设置为'false'才为false
|
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true,只有明确设置为'false'才为false
|
||||||
|
|
||||||
|
const tokenExpiresAt = accountData.expiresAt || null
|
||||||
|
const subscriptionExpiresAt =
|
||||||
|
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== ''
|
||||||
|
? accountData.subscriptionExpiresAt
|
||||||
|
: null
|
||||||
|
|
||||||
// 不解密敏感字段,只返回基本信息
|
// 不解密敏感字段,只返回基本信息
|
||||||
accounts.push({
|
accounts.push({
|
||||||
...accountData,
|
...accountData,
|
||||||
geminiOauth: accountData.geminiOauth ? '[ENCRYPTED]' : '',
|
geminiOauth: accountData.geminiOauth ? '[ENCRYPTED]' : '',
|
||||||
accessToken: accountData.accessToken ? '[ENCRYPTED]' : '',
|
accessToken: accountData.accessToken ? '[ENCRYPTED]' : '',
|
||||||
refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '',
|
refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '',
|
||||||
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
|
|
||||||
|
// ✅ 前端显示订阅过期时间(业务字段)
|
||||||
|
// 注意:前端看到的 expiresAt 实际上是 subscriptionExpiresAt
|
||||||
|
tokenExpiresAt,
|
||||||
|
subscriptionExpiresAt,
|
||||||
|
expiresAt: subscriptionExpiresAt,
|
||||||
|
|
||||||
// 添加 scopes 字段用于判断认证方式
|
// 添加 scopes 字段用于判断认证方式
|
||||||
// 处理空字符串和默认值的情况
|
// 处理空字符串和默认值的情况
|
||||||
scopes:
|
scopes:
|
||||||
@@ -762,8 +751,17 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
|
|||||||
|
|
||||||
for (const accountId of sharedAccountIds) {
|
for (const accountId of sharedAccountIds) {
|
||||||
const account = await getAccount(accountId)
|
const account = await getAccount(accountId)
|
||||||
if (account && account.isActive === 'true' && !isRateLimited(account)) {
|
if (
|
||||||
|
account &&
|
||||||
|
account.isActive === 'true' &&
|
||||||
|
!isRateLimited(account) &&
|
||||||
|
!isSubscriptionExpired(account)
|
||||||
|
) {
|
||||||
availableAccounts.push(account)
|
availableAccounts.push(account)
|
||||||
|
} else if (account && isSubscriptionExpired(account)) {
|
||||||
|
logger.debug(
|
||||||
|
`⏰ Skipping expired Gemini account: ${account.name}, expired at ${account.subscriptionExpiresAt}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -818,6 +816,19 @@ function isTokenExpired(account) {
|
|||||||
return now >= expiryTime - buffer
|
return now >= expiryTime - buffer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查账户订阅是否过期
|
||||||
|
* @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()
|
||||||
|
}
|
||||||
|
|
||||||
// 检查账户是否被限流
|
// 检查账户是否被限流
|
||||||
function isRateLimited(account) {
|
function isRateLimited(account) {
|
||||||
if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
|
if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
|
||||||
|
|||||||
266
src/services/modelService.js
Normal file
266
src/services/modelService.js
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型服务
|
||||||
|
* 管理系统支持的 AI 模型列表
|
||||||
|
* 与 pricingService 独立,专注于"支持哪些模型"而不是"如何计费"
|
||||||
|
*/
|
||||||
|
class ModelService {
|
||||||
|
constructor() {
|
||||||
|
this.modelsFile = path.join(process.cwd(), 'data', 'supported_models.json')
|
||||||
|
this.supportedModels = null
|
||||||
|
this.fileWatcher = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化模型服务
|
||||||
|
*/
|
||||||
|
async initialize() {
|
||||||
|
try {
|
||||||
|
this.loadModels()
|
||||||
|
this.setupFileWatcher()
|
||||||
|
logger.success('✅ Model service initialized successfully')
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to initialize model service:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载支持的模型配置
|
||||||
|
*/
|
||||||
|
loadModels() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(this.modelsFile)) {
|
||||||
|
const data = fs.readFileSync(this.modelsFile, 'utf8')
|
||||||
|
this.supportedModels = JSON.parse(data)
|
||||||
|
|
||||||
|
const totalModels = Object.values(this.supportedModels).reduce(
|
||||||
|
(sum, config) => sum + config.models.length,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(`📋 Loaded ${totalModels} supported models from configuration`)
|
||||||
|
} else {
|
||||||
|
logger.warn('⚠️ Supported models file not found, using defaults')
|
||||||
|
this.supportedModels = this.getDefaultModels()
|
||||||
|
|
||||||
|
// 创建默认配置文件
|
||||||
|
this.saveDefaultConfig()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to load supported models:', error)
|
||||||
|
this.supportedModels = this.getDefaultModels()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取默认模型配置(后备方案)
|
||||||
|
*/
|
||||||
|
getDefaultModels() {
|
||||||
|
return {
|
||||||
|
claude: {
|
||||||
|
provider: 'anthropic',
|
||||||
|
description: 'Claude models from Anthropic',
|
||||||
|
models: [
|
||||||
|
'claude-sonnet-4-5-20250929',
|
||||||
|
'claude-opus-4-1-20250805',
|
||||||
|
'claude-sonnet-4-20250514',
|
||||||
|
'claude-opus-4-20250514',
|
||||||
|
'claude-3-7-sonnet-20250219',
|
||||||
|
'claude-3-5-sonnet-20241022',
|
||||||
|
'claude-3-5-haiku-20241022',
|
||||||
|
'claude-3-opus-20240229',
|
||||||
|
'claude-3-haiku-20240307'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
openai: {
|
||||||
|
provider: 'openai',
|
||||||
|
description: 'OpenAI GPT models',
|
||||||
|
models: [
|
||||||
|
'gpt-4o',
|
||||||
|
'gpt-4o-mini',
|
||||||
|
'gpt-4.1',
|
||||||
|
'gpt-4.1-mini',
|
||||||
|
'gpt-4.1-nano',
|
||||||
|
'gpt-4-turbo',
|
||||||
|
'gpt-4',
|
||||||
|
'gpt-3.5-turbo',
|
||||||
|
'o3',
|
||||||
|
'o4-mini',
|
||||||
|
'chatgpt-4o-latest'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
gemini: {
|
||||||
|
provider: 'google',
|
||||||
|
description: 'Google Gemini models',
|
||||||
|
models: [
|
||||||
|
'gemini-1.5-pro',
|
||||||
|
'gemini-1.5-flash',
|
||||||
|
'gemini-2.0-flash',
|
||||||
|
'gemini-2.0-flash-exp',
|
||||||
|
'gemini-2.0-flash-thinking',
|
||||||
|
'gemini-2.0-flash-thinking-exp',
|
||||||
|
'gemini-2.0-pro',
|
||||||
|
'gemini-2.5-flash',
|
||||||
|
'gemini-2.5-flash-lite',
|
||||||
|
'gemini-2.5-pro'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存默认配置到文件
|
||||||
|
*/
|
||||||
|
saveDefaultConfig() {
|
||||||
|
try {
|
||||||
|
const dataDir = path.dirname(this.modelsFile)
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(this.modelsFile, JSON.stringify(this.supportedModels, null, 2))
|
||||||
|
logger.info('💾 Created default supported_models.json configuration')
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to save default config:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有支持的模型(OpenAI API 格式)
|
||||||
|
*/
|
||||||
|
getAllModels() {
|
||||||
|
const models = []
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
for (const [_service, config] of Object.entries(this.supportedModels)) {
|
||||||
|
for (const modelId of config.models) {
|
||||||
|
models.push({
|
||||||
|
id: modelId,
|
||||||
|
object: 'model',
|
||||||
|
created: now,
|
||||||
|
owned_by: config.provider
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return models.sort((a, b) => {
|
||||||
|
// 先按 provider 排序,再按 model id 排序
|
||||||
|
if (a.owned_by !== b.owned_by) {
|
||||||
|
return a.owned_by.localeCompare(b.owned_by)
|
||||||
|
}
|
||||||
|
return a.id.localeCompare(b.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 provider 获取模型
|
||||||
|
* @param {string} provider - 'anthropic', 'openai', 'google' 等
|
||||||
|
*/
|
||||||
|
getModelsByProvider(provider) {
|
||||||
|
return this.getAllModels().filter((m) => m.owned_by === provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查模型是否被支持
|
||||||
|
* @param {string} modelId - 模型 ID
|
||||||
|
*/
|
||||||
|
isModelSupported(modelId) {
|
||||||
|
if (!modelId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return this.getAllModels().some((m) => m.id === modelId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取模型的 provider
|
||||||
|
* @param {string} modelId - 模型 ID
|
||||||
|
*/
|
||||||
|
getModelProvider(modelId) {
|
||||||
|
const model = this.getAllModels().find((m) => m.id === modelId)
|
||||||
|
return model ? model.owned_by : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新加载模型配置
|
||||||
|
*/
|
||||||
|
reloadModels() {
|
||||||
|
logger.info('🔄 Reloading supported models configuration...')
|
||||||
|
this.loadModels()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置文件监听器(监听配置文件变化)
|
||||||
|
*/
|
||||||
|
setupFileWatcher() {
|
||||||
|
try {
|
||||||
|
// 如果已有监听器,先关闭
|
||||||
|
if (this.fileWatcher) {
|
||||||
|
this.fileWatcher.close()
|
||||||
|
this.fileWatcher = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有文件存在时才设置监听器
|
||||||
|
if (!fs.existsSync(this.modelsFile)) {
|
||||||
|
logger.debug('📋 Models file does not exist yet, skipping file watcher setup')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 fs.watchFile 监听文件变化
|
||||||
|
const watchOptions = {
|
||||||
|
persistent: true,
|
||||||
|
interval: 60000 // 每60秒检查一次
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastMtime = fs.statSync(this.modelsFile).mtimeMs
|
||||||
|
|
||||||
|
fs.watchFile(this.modelsFile, watchOptions, (curr, _prev) => {
|
||||||
|
if (curr.mtimeMs !== lastMtime) {
|
||||||
|
lastMtime = curr.mtimeMs
|
||||||
|
logger.info('📋 Detected change in supported_models.json, reloading...')
|
||||||
|
this.reloadModels()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 保存引用以便清理
|
||||||
|
this.fileWatcher = {
|
||||||
|
close: () => fs.unwatchFile(this.modelsFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('👁️ File watcher set up for supported_models.json')
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to setup file watcher:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取服务状态
|
||||||
|
*/
|
||||||
|
getStatus() {
|
||||||
|
const totalModels = this.supportedModels
|
||||||
|
? Object.values(this.supportedModels).reduce((sum, config) => sum + config.models.length, 0)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialized: this.supportedModels !== null,
|
||||||
|
totalModels,
|
||||||
|
providers: this.supportedModels ? Object.keys(this.supportedModels) : [],
|
||||||
|
fileExists: fs.existsSync(this.modelsFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理资源
|
||||||
|
*/
|
||||||
|
cleanup() {
|
||||||
|
if (this.fileWatcher) {
|
||||||
|
this.fileWatcher.close()
|
||||||
|
this.fileWatcher = null
|
||||||
|
logger.debug('📋 Model service file watcher closed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new ModelService()
|
||||||
@@ -194,19 +194,6 @@ function buildCodexUsageSnapshot(accountData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSubscriptionExpiresAt(value) {
|
|
||||||
if (value === undefined || value === null || value === '') {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = value instanceof Date ? value : new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return date.toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新访问令牌
|
// 刷新访问令牌
|
||||||
async function refreshAccessToken(refreshToken, proxy = null) {
|
async function refreshAccessToken(refreshToken, proxy = null) {
|
||||||
try {
|
try {
|
||||||
@@ -347,6 +334,19 @@ function isTokenExpired(account) {
|
|||||||
return new Date(account.expiresAt) <= new Date()
|
return new Date(account.expiresAt) <= new Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查账户订阅是否过期
|
||||||
|
* @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()
|
||||||
|
}
|
||||||
|
|
||||||
// 刷新账户的 access token(带分布式锁)
|
// 刷新账户的 access token(带分布式锁)
|
||||||
async function refreshAccountToken(accountId) {
|
async function refreshAccountToken(accountId) {
|
||||||
let lockAcquired = false
|
let lockAcquired = false
|
||||||
@@ -530,13 +530,6 @@ async function createAccount(accountData) {
|
|||||||
// 处理账户信息
|
// 处理账户信息
|
||||||
const accountInfo = accountData.accountInfo || {}
|
const accountInfo = accountData.accountInfo || {}
|
||||||
|
|
||||||
const tokenExpiresAt = oauthData.expires_in
|
|
||||||
? new Date(Date.now() + oauthData.expires_in * 1000).toISOString()
|
|
||||||
: ''
|
|
||||||
const subscriptionExpiresAt = normalizeSubscriptionExpiresAt(
|
|
||||||
accountData.subscriptionExpiresAt || accountInfo.subscriptionExpiresAt || ''
|
|
||||||
)
|
|
||||||
|
|
||||||
// 检查邮箱是否已经是加密格式(包含冒号分隔的32位十六进制字符)
|
// 检查邮箱是否已经是加密格式(包含冒号分隔的32位十六进制字符)
|
||||||
const isEmailEncrypted =
|
const isEmailEncrypted =
|
||||||
accountInfo.email && accountInfo.email.length >= 33 && accountInfo.email.charAt(32) === ':'
|
accountInfo.email && accountInfo.email.length >= 33 && accountInfo.email.charAt(32) === ':'
|
||||||
@@ -573,8 +566,13 @@ async function createAccount(accountData) {
|
|||||||
email: isEmailEncrypted ? accountInfo.email : encrypt(accountInfo.email || ''),
|
email: isEmailEncrypted ? accountInfo.email : encrypt(accountInfo.email || ''),
|
||||||
emailVerified: accountInfo.emailVerified === true ? 'true' : 'false',
|
emailVerified: accountInfo.emailVerified === true ? 'true' : 'false',
|
||||||
// 过期时间
|
// 过期时间
|
||||||
expiresAt: tokenExpiresAt,
|
expiresAt: oauthData.expires_in
|
||||||
subscriptionExpiresAt,
|
? new Date(Date.now() + oauthData.expires_in * 1000).toISOString()
|
||||||
|
: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), // OAuth Token 过期时间(技术字段)
|
||||||
|
|
||||||
|
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
|
||||||
|
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
|
||||||
|
|
||||||
// 状态字段
|
// 状态字段
|
||||||
isActive: accountData.isActive !== false ? 'true' : 'false',
|
isActive: accountData.isActive !== false ? 'true' : 'false',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
@@ -599,10 +597,7 @@ async function createAccount(accountData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Created OpenAI account: ${accountId}`)
|
logger.info(`Created OpenAI account: ${accountId}`)
|
||||||
return {
|
return account
|
||||||
...account,
|
|
||||||
subscriptionExpiresAt: account.subscriptionExpiresAt || null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取账户
|
// 获取账户
|
||||||
@@ -645,11 +640,6 @@ async function getAccount(accountId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
accountData.subscriptionExpiresAt =
|
|
||||||
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== ''
|
|
||||||
? accountData.subscriptionExpiresAt
|
|
||||||
: null
|
|
||||||
|
|
||||||
return accountData
|
return accountData
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,16 +673,18 @@ async function updateAccount(accountId, updates) {
|
|||||||
updates.email = encrypt(updates.email)
|
updates.email = encrypt(updates.email)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) {
|
|
||||||
updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.subscriptionExpiresAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理代理配置
|
// 处理代理配置
|
||||||
if (updates.proxy) {
|
if (updates.proxy) {
|
||||||
updates.proxy =
|
updates.proxy =
|
||||||
typeof updates.proxy === 'string' ? updates.proxy : JSON.stringify(updates.proxy)
|
typeof updates.proxy === 'string' ? updates.proxy : JSON.stringify(updates.proxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ 如果通过路由映射更新了 subscriptionExpiresAt,直接保存
|
||||||
|
// subscriptionExpiresAt 是业务字段,与 token 刷新独立
|
||||||
|
if (updates.subscriptionExpiresAt !== undefined) {
|
||||||
|
// 直接保存,不做任何调整
|
||||||
|
}
|
||||||
|
|
||||||
// 更新账户类型时处理共享账户集合
|
// 更新账户类型时处理共享账户集合
|
||||||
const client = redisClient.getClientSafe()
|
const client = redisClient.getClientSafe()
|
||||||
if (updates.accountType && updates.accountType !== existingAccount.accountType) {
|
if (updates.accountType && updates.accountType !== existingAccount.accountType) {
|
||||||
@@ -719,10 +711,6 @@ async function updateAccount(accountId, updates) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!updatedAccount.subscriptionExpiresAt) {
|
|
||||||
updatedAccount.subscriptionExpiresAt = null
|
|
||||||
}
|
|
||||||
|
|
||||||
return updatedAccount
|
return updatedAccount
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -805,7 +793,11 @@ async function getAllAccounts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriptionExpiresAt = accountData.subscriptionExpiresAt || null
|
const tokenExpiresAt = accountData.expiresAt || null
|
||||||
|
const subscriptionExpiresAt =
|
||||||
|
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== ''
|
||||||
|
? accountData.subscriptionExpiresAt
|
||||||
|
: null
|
||||||
|
|
||||||
// 不解密敏感字段,只返回基本信息
|
// 不解密敏感字段,只返回基本信息
|
||||||
accounts.push({
|
accounts.push({
|
||||||
@@ -815,13 +807,18 @@ async function getAllAccounts() {
|
|||||||
openaiOauth: maskedOauth,
|
openaiOauth: maskedOauth,
|
||||||
accessToken: maskedAccessToken,
|
accessToken: maskedAccessToken,
|
||||||
refreshToken: maskedRefreshToken,
|
refreshToken: maskedRefreshToken,
|
||||||
|
|
||||||
|
// ✅ 前端显示订阅过期时间(业务字段)
|
||||||
|
tokenExpiresAt,
|
||||||
|
subscriptionExpiresAt,
|
||||||
|
expiresAt: subscriptionExpiresAt,
|
||||||
|
|
||||||
// 添加 scopes 字段用于判断认证方式
|
// 添加 scopes 字段用于判断认证方式
|
||||||
// 处理空字符串的情况
|
// 处理空字符串的情况
|
||||||
scopes:
|
scopes:
|
||||||
accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [],
|
accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [],
|
||||||
// 添加 hasRefreshToken 标记
|
// 添加 hasRefreshToken 标记
|
||||||
hasRefreshToken: hasRefreshTokenFlag,
|
hasRefreshToken: hasRefreshTokenFlag,
|
||||||
subscriptionExpiresAt,
|
|
||||||
// 添加限流状态信息(统一格式)
|
// 添加限流状态信息(统一格式)
|
||||||
rateLimitStatus: rateLimitInfo
|
rateLimitStatus: rateLimitInfo
|
||||||
? {
|
? {
|
||||||
@@ -940,8 +937,17 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
|
|||||||
|
|
||||||
for (const accountId of sharedAccountIds) {
|
for (const accountId of sharedAccountIds) {
|
||||||
const account = await getAccount(accountId)
|
const account = await getAccount(accountId)
|
||||||
if (account && account.isActive === 'true' && !isRateLimited(account)) {
|
if (
|
||||||
|
account &&
|
||||||
|
account.isActive === 'true' &&
|
||||||
|
!isRateLimited(account) &&
|
||||||
|
!isSubscriptionExpired(account)
|
||||||
|
) {
|
||||||
availableAccounts.push(account)
|
availableAccounts.push(account)
|
||||||
|
} else if (account && isSubscriptionExpired(account)) {
|
||||||
|
logger.debug(
|
||||||
|
`⏰ Skipping expired OpenAI account: ${account.name}, expired at ${account.subscriptionExpiresAt}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,19 +5,6 @@ const logger = require('../utils/logger')
|
|||||||
const config = require('../../config/config')
|
const config = require('../../config/config')
|
||||||
const LRUCache = require('../utils/lruCache')
|
const LRUCache = require('../utils/lruCache')
|
||||||
|
|
||||||
function normalizeSubscriptionExpiresAt(value) {
|
|
||||||
if (value === undefined || value === null || value === '') {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = value instanceof Date ? value : new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return date.toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
class OpenAIResponsesAccountService {
|
class OpenAIResponsesAccountService {
|
||||||
constructor() {
|
constructor() {
|
||||||
// 加密相关常量
|
// 加密相关常量
|
||||||
@@ -62,8 +49,7 @@ class OpenAIResponsesAccountService {
|
|||||||
schedulable = true, // 是否可被调度
|
schedulable = true, // 是否可被调度
|
||||||
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
||||||
quotaResetTime = '00:00', // 额度重置时间(HH:mm格式)
|
quotaResetTime = '00:00', // 额度重置时间(HH:mm格式)
|
||||||
rateLimitDuration = 60, // 限流时间(分钟)
|
rateLimitDuration = 60 // 限流时间(分钟)
|
||||||
subscriptionExpiresAt = null
|
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
// 验证必填字段
|
// 验证必填字段
|
||||||
@@ -89,6 +75,11 @@ class OpenAIResponsesAccountService {
|
|||||||
isActive: isActive.toString(),
|
isActive: isActive.toString(),
|
||||||
accountType,
|
accountType,
|
||||||
schedulable: schedulable.toString(),
|
schedulable: schedulable.toString(),
|
||||||
|
|
||||||
|
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
|
||||||
|
// 注意:OpenAI-Responses 使用 API Key 认证,没有 OAuth token,因此没有 expiresAt
|
||||||
|
subscriptionExpiresAt: options.subscriptionExpiresAt || null,
|
||||||
|
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
lastUsedAt: '',
|
lastUsedAt: '',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
@@ -102,8 +93,7 @@ class OpenAIResponsesAccountService {
|
|||||||
dailyUsage: '0',
|
dailyUsage: '0',
|
||||||
lastResetDate: redis.getDateStringInTimezone(),
|
lastResetDate: redis.getDateStringInTimezone(),
|
||||||
quotaResetTime,
|
quotaResetTime,
|
||||||
quotaStoppedAt: '',
|
quotaStoppedAt: ''
|
||||||
subscriptionExpiresAt: normalizeSubscriptionExpiresAt(subscriptionExpiresAt)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存到 Redis
|
// 保存到 Redis
|
||||||
@@ -113,7 +103,6 @@ class OpenAIResponsesAccountService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...accountData,
|
...accountData,
|
||||||
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
|
|
||||||
apiKey: '***' // 返回时隐藏敏感信息
|
apiKey: '***' // 返回时隐藏敏感信息
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,11 +129,6 @@ class OpenAIResponsesAccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
accountData.subscriptionExpiresAt =
|
|
||||||
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== ''
|
|
||||||
? accountData.subscriptionExpiresAt
|
|
||||||
: null
|
|
||||||
|
|
||||||
return accountData
|
return accountData
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,11 +156,10 @@ class OpenAIResponsesAccountService {
|
|||||||
: updates.baseApi
|
: updates.baseApi
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) {
|
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
||||||
updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.subscriptionExpiresAt)
|
// OpenAI-Responses 使用 API Key,没有 token 刷新逻辑,不会覆盖此字段
|
||||||
} else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt')) {
|
if (updates.subscriptionExpiresAt !== undefined) {
|
||||||
updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.expiresAt)
|
// 直接保存,不做任何调整
|
||||||
delete updates.expiresAt
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新 Redis
|
// 更新 Redis
|
||||||
@@ -240,6 +223,10 @@ class OpenAIResponsesAccountService {
|
|||||||
// 转换 isActive 字段为布尔值
|
// 转换 isActive 字段为布尔值
|
||||||
account.isActive = account.isActive === 'true'
|
account.isActive = account.isActive === 'true'
|
||||||
|
|
||||||
|
// ✅ 前端显示订阅过期时间(业务字段)
|
||||||
|
account.expiresAt = account.subscriptionExpiresAt || null
|
||||||
|
account.platform = account.platform || 'openai-responses'
|
||||||
|
|
||||||
accounts.push(account)
|
accounts.push(account)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,10 +272,10 @@ class OpenAIResponsesAccountService {
|
|||||||
accountData.schedulable = accountData.schedulable !== 'false'
|
accountData.schedulable = accountData.schedulable !== 'false'
|
||||||
// 转换 isActive 字段为布尔值
|
// 转换 isActive 字段为布尔值
|
||||||
accountData.isActive = accountData.isActive === 'true'
|
accountData.isActive = accountData.isActive === 'true'
|
||||||
accountData.subscriptionExpiresAt =
|
|
||||||
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== ''
|
// ✅ 前端显示订阅过期时间(业务字段)
|
||||||
? accountData.subscriptionExpiresAt
|
accountData.expiresAt = accountData.subscriptionExpiresAt || null
|
||||||
: null
|
accountData.platform = accountData.platform || 'openai-responses'
|
||||||
|
|
||||||
accounts.push(accountData)
|
accounts.push(accountData)
|
||||||
}
|
}
|
||||||
@@ -536,6 +523,25 @@ class OpenAIResponsesAccountService {
|
|||||||
return { success: true, message: 'Account status reset successfully' }
|
return { success: true, message: 'Account status reset successfully' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⏰ 检查账户订阅是否已过期
|
||||||
|
isSubscriptionExpired(account) {
|
||||||
|
if (!account.subscriptionExpiresAt) {
|
||||||
|
return false // 未设置过期时间,视为永不过期
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiryDate = new Date(account.subscriptionExpiresAt)
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
if (expiryDate <= now) {
|
||||||
|
logger.debug(
|
||||||
|
`⏰ OpenAI-Responses Account ${account.name} (${account.id}) subscription expired at ${account.subscriptionExpiresAt}`
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// 获取限流信息
|
// 获取限流信息
|
||||||
_getRateLimitInfo(accountData) {
|
_getRateLimitInfo(accountData) {
|
||||||
if (accountData.rateLimitStatus !== 'limited') {
|
if (accountData.rateLimitStatus !== 'limited') {
|
||||||
|
|||||||
@@ -31,10 +31,25 @@ class OpenAIToClaudeConverter {
|
|||||||
stream: openaiRequest.stream || false
|
stream: openaiRequest.stream || false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Claude Code 必需的系统消息
|
// 定义 Claude Code 的默认系统提示词
|
||||||
const claudeCodeSystemMessage = "You are Claude Code, Anthropic's official CLI for Claude."
|
const claudeCodeSystemMessage = "You are Claude Code, Anthropic's official CLI for Claude."
|
||||||
|
|
||||||
claudeRequest.system = claudeCodeSystemMessage
|
// 如果 OpenAI 请求中包含系统消息,提取并检查
|
||||||
|
const systemMessage = this._extractSystemMessage(openaiRequest.messages)
|
||||||
|
if (systemMessage && systemMessage.includes('You are currently in Xcode')) {
|
||||||
|
// Xcode 系统提示词
|
||||||
|
claudeRequest.system = systemMessage
|
||||||
|
logger.info(
|
||||||
|
`🔍 Xcode request detected, using Xcode system prompt (${systemMessage.length} chars)`
|
||||||
|
)
|
||||||
|
logger.debug(`📋 System prompt preview: ${systemMessage.substring(0, 150)}...`)
|
||||||
|
} else {
|
||||||
|
// 使用 Claude Code 默认系统提示词
|
||||||
|
claudeRequest.system = claudeCodeSystemMessage
|
||||||
|
logger.debug(
|
||||||
|
`📋 Using Claude Code default system prompt${systemMessage ? ' (ignored custom prompt)' : ''}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 处理停止序列
|
// 处理停止序列
|
||||||
if (openaiRequest.stop) {
|
if (openaiRequest.stop) {
|
||||||
|
|||||||
@@ -545,6 +545,14 @@ class UnifiedClaudeScheduler {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查订阅是否过期
|
||||||
|
if (claudeConsoleAccountService.isSubscriptionExpired(account)) {
|
||||||
|
logger.debug(
|
||||||
|
`⏰ Claude Console account ${account.name} (${account.id}) expired at ${account.subscriptionExpiresAt}`
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// 主动触发一次额度检查,确保状态即时生效
|
// 主动触发一次额度检查,确保状态即时生效
|
||||||
try {
|
try {
|
||||||
await claudeConsoleAccountService.checkQuotaUsage(account.id)
|
await claudeConsoleAccountService.checkQuotaUsage(account.id)
|
||||||
@@ -642,6 +650,14 @@ class UnifiedClaudeScheduler {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查订阅是否过期
|
||||||
|
if (ccrAccountService.isSubscriptionExpired(account)) {
|
||||||
|
logger.debug(
|
||||||
|
`⏰ CCR account ${account.name} (${account.id}) expired at ${account.subscriptionExpiresAt}`
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否被限流
|
// 检查是否被限流
|
||||||
const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id)
|
const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id)
|
||||||
const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id)
|
const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id)
|
||||||
@@ -774,6 +790,13 @@ class UnifiedClaudeScheduler {
|
|||||||
) {
|
) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
// 检查订阅是否过期
|
||||||
|
if (claudeConsoleAccountService.isSubscriptionExpired(account)) {
|
||||||
|
logger.debug(
|
||||||
|
`⏰ Claude Console account ${account.name} (${accountId}) expired at ${account.subscriptionExpiresAt} (session check)`
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
// 检查是否超额
|
// 检查是否超额
|
||||||
try {
|
try {
|
||||||
await claudeConsoleAccountService.checkQuotaUsage(accountId)
|
await claudeConsoleAccountService.checkQuotaUsage(accountId)
|
||||||
@@ -832,6 +855,13 @@ class UnifiedClaudeScheduler {
|
|||||||
if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel, 'in session check')) {
|
if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel, 'in session check')) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
// 检查订阅是否过期
|
||||||
|
if (ccrAccountService.isSubscriptionExpired(account)) {
|
||||||
|
logger.debug(
|
||||||
|
`⏰ CCR account ${account.name} (${accountId}) expired at ${account.subscriptionExpiresAt} (session check)`
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
// 检查是否超额
|
// 检查是否超额
|
||||||
try {
|
try {
|
||||||
await ccrAccountService.checkQuotaUsage(accountId)
|
await ccrAccountService.checkQuotaUsage(accountId)
|
||||||
@@ -1353,6 +1383,14 @@ class UnifiedClaudeScheduler {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查订阅是否过期
|
||||||
|
if (ccrAccountService.isSubscriptionExpired(account)) {
|
||||||
|
logger.debug(
|
||||||
|
`⏰ CCR account ${account.name} (${account.id}) expired at ${account.subscriptionExpiresAt}`
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否被限流或超额
|
// 检查是否被限流或超额
|
||||||
const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id)
|
const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id)
|
||||||
const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id)
|
const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id)
|
||||||
|
|||||||
@@ -211,6 +211,15 @@ class UnifiedOpenAIScheduler {
|
|||||||
error.statusCode = 403 // Forbidden - 调度被禁止
|
error.statusCode = 403 // Forbidden - 调度被禁止
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⏰ 检查 OpenAI-Responses 专属账户订阅是否过期
|
||||||
|
if (openaiResponsesAccountService.isSubscriptionExpired(boundAccount)) {
|
||||||
|
const errorMsg = `Dedicated account ${boundAccount.name} subscription has expired`
|
||||||
|
logger.warn(`⚠️ ${errorMsg}`)
|
||||||
|
const error = new Error(errorMsg)
|
||||||
|
error.statusCode = 403 // Forbidden - 订阅已过期
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 专属账户:可选的模型检查(只有明确配置了supportedModels且不为空才检查)
|
// 专属账户:可选的模型检查(只有明确配置了supportedModels且不为空才检查)
|
||||||
@@ -461,6 +470,14 @@ class UnifiedOpenAIScheduler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⏰ 检查订阅是否过期
|
||||||
|
if (openaiResponsesAccountService.isSubscriptionExpired(account)) {
|
||||||
|
logger.debug(
|
||||||
|
`⏭️ Skipping OpenAI-Responses account ${account.name} - subscription expired`
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// OpenAI-Responses 账户默认支持所有模型
|
// OpenAI-Responses 账户默认支持所有模型
|
||||||
// 因为它们是第三方兼容 API,模型支持由第三方决定
|
// 因为它们是第三方兼容 API,模型支持由第三方决定
|
||||||
|
|
||||||
@@ -536,6 +553,11 @@ class UnifiedOpenAIScheduler {
|
|||||||
logger.info(`🚫 OpenAI-Responses account ${accountId} is not schedulable`)
|
logger.info(`🚫 OpenAI-Responses account ${accountId} is not schedulable`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
// ⏰ 检查订阅是否过期
|
||||||
|
if (openaiResponsesAccountService.isSubscriptionExpired(account)) {
|
||||||
|
logger.info(`🚫 OpenAI-Responses account ${accountId} subscription expired`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
// 检查并清除过期的限流状态
|
// 检查并清除过期的限流状态
|
||||||
const isRateLimitCleared =
|
const isRateLimitCleared =
|
||||||
await openaiResponsesAccountService.checkAndClearRateLimit(accountId)
|
await openaiResponsesAccountService.checkAndClearRateLimit(accountId)
|
||||||
|
|||||||
121
src/utils/runtimeAddon.js
Normal file
121
src/utils/runtimeAddon.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const logger = require('./logger')
|
||||||
|
|
||||||
|
const ADDON_DIRECTORIES = [
|
||||||
|
path.join(process.cwd(), '.local', 'ext'),
|
||||||
|
path.join(process.cwd(), '.local', 'extensions')
|
||||||
|
]
|
||||||
|
|
||||||
|
class RuntimeAddonBus {
|
||||||
|
constructor() {
|
||||||
|
this._handlers = new Map()
|
||||||
|
this._initialized = false
|
||||||
|
}
|
||||||
|
|
||||||
|
register(eventId, handler) {
|
||||||
|
if (!eventId || typeof handler !== 'function') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._handlers.has(eventId)) {
|
||||||
|
this._handlers.set(eventId, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
this._handlers.get(eventId).push(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
emitSync(eventId, payload) {
|
||||||
|
this._ensureInitialized()
|
||||||
|
|
||||||
|
if (!eventId) {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlers = this._handlers.get(eventId)
|
||||||
|
if (!handlers || handlers.length === 0) {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = payload
|
||||||
|
|
||||||
|
for (const handler of handlers) {
|
||||||
|
try {
|
||||||
|
const result = handler(current)
|
||||||
|
if (typeof result !== 'undefined') {
|
||||||
|
current = result
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this._log('warn', `本地扩展处理 ${eventId} 失败: ${error.message}`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensureInitialized() {
|
||||||
|
if (this._initialized) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this._initialized = true
|
||||||
|
const loadedPaths = new Set()
|
||||||
|
|
||||||
|
for (const dir of ADDON_DIRECTORIES) {
|
||||||
|
if (!dir || !fs.existsSync(dir)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = []
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||||
|
} catch (error) {
|
||||||
|
this._log('warn', `读取本地扩展目录 ${dir} 失败: ${error.message}`, error)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.name.endsWith('.js')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPath = path.join(dir, entry.name)
|
||||||
|
|
||||||
|
if (loadedPaths.has(targetPath)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedPaths.add(targetPath)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const registrar = require(targetPath)
|
||||||
|
if (typeof registrar === 'function') {
|
||||||
|
registrar(this)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this._log('warn', `加载本地扩展 ${entry.name} 失败: ${error.message}`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_log(level, message, error) {
|
||||||
|
const targetLevel = typeof level === 'string' ? level : 'info'
|
||||||
|
const loggerMethod =
|
||||||
|
logger && typeof logger[targetLevel] === 'function' ? logger[targetLevel].bind(logger) : null
|
||||||
|
|
||||||
|
if (loggerMethod) {
|
||||||
|
loggerMethod(message, error)
|
||||||
|
} else if (targetLevel === 'error') {
|
||||||
|
console.error(message, error)
|
||||||
|
} else {
|
||||||
|
console.log(message, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new RuntimeAddonBus()
|
||||||
@@ -304,7 +304,25 @@ const selectQuickOption = (value) => {
|
|||||||
// 更新自定义过期时间
|
// 更新自定义过期时间
|
||||||
const updateCustomExpiryPreview = () => {
|
const updateCustomExpiryPreview = () => {
|
||||||
if (localForm.customExpireDate) {
|
if (localForm.customExpireDate) {
|
||||||
localForm.expiresAt = new Date(localForm.customExpireDate).toISOString()
|
try {
|
||||||
|
// 手动解析日期时间字符串,确保它被正确解释为本地时间
|
||||||
|
const [datePart, timePart] = localForm.customExpireDate.split('T')
|
||||||
|
const [year, month, day] = datePart.split('-').map(Number)
|
||||||
|
const [hours, minutes] = timePart.split(':').map(Number)
|
||||||
|
|
||||||
|
// 使用构造函数创建本地时间的 Date 对象,然后转换为 UTC ISO 字符串
|
||||||
|
const localDate = new Date(year, month - 1, day, hours, minutes, 0, 0)
|
||||||
|
|
||||||
|
// 验证日期有效性
|
||||||
|
if (isNaN(localDate.getTime())) {
|
||||||
|
console.error('Invalid date:', localForm.customExpireDate)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
localForm.expiresAt = localDate.toISOString()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse custom expire date:', error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4841,11 +4841,23 @@ const handleGroupRefresh = async () => {
|
|||||||
// 处理 API Key 管理模态框刷新
|
// 处理 API Key 管理模态框刷新
|
||||||
const handleApiKeyRefresh = async () => {
|
const handleApiKeyRefresh = async () => {
|
||||||
// 刷新账户信息以更新 API Key 数量
|
// 刷新账户信息以更新 API Key 数量
|
||||||
if (props.account?.id) {
|
if (!props.account?.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshers = [
|
||||||
|
typeof accountsStore.fetchDroidAccounts === 'function'
|
||||||
|
? accountsStore.fetchDroidAccounts
|
||||||
|
: null,
|
||||||
|
typeof accountsStore.fetchAllAccounts === 'function' ? accountsStore.fetchAllAccounts : null
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
for (const refresher of refreshers) {
|
||||||
try {
|
try {
|
||||||
await accountsStore.fetchAccounts()
|
await refresher()
|
||||||
|
return
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to refresh account data:', error)
|
console.error('刷新账户列表失败:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,12 +20,28 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="flex items-center gap-2">
|
||||||
class="p-1 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-300"
|
<button
|
||||||
@click="$emit('close')"
|
class="flex items-center gap-2 rounded-lg border border-purple-200 bg-white/90 px-3 py-1.5 text-xs font-semibold text-purple-600 shadow-sm transition-all duration-200 hover:border-purple-300 hover:bg-purple-50 hover:text-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-200 disabled:cursor-not-allowed disabled:opacity-60 dark:border-purple-600/60 dark:bg-purple-900/20 dark:text-purple-200 dark:hover:border-purple-500 dark:hover:bg-purple-900/40 dark:hover:text-purple-100 dark:focus:ring-purple-500/40 sm:text-sm"
|
||||||
>
|
:disabled="loading || apiKeys.length === 0 || copyingAll"
|
||||||
<i class="fas fa-times text-lg sm:text-xl" />
|
@click="copyAllApiKeys"
|
||||||
</button>
|
>
|
||||||
|
<i
|
||||||
|
:class="[
|
||||||
|
'text-sm sm:text-base',
|
||||||
|
copyingAll ? 'fas fa-spinner fa-spin' : 'fas fa-clipboard-list'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<span>复制全部 Key</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex h-9 w-9 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:text-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:text-gray-200 sm:h-10 sm:w-10"
|
||||||
|
title="关闭"
|
||||||
|
@click="$emit('close')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times text-base sm:text-lg" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 加载状态 -->
|
<!-- 加载状态 -->
|
||||||
@@ -393,6 +409,7 @@ const resetting = ref(null)
|
|||||||
const apiKeys = ref([])
|
const apiKeys = ref([])
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageSize = ref(15)
|
const pageSize = ref(15)
|
||||||
|
const copyingAll = ref(false)
|
||||||
|
|
||||||
// 新增:筛选和搜索相关状态
|
// 新增:筛选和搜索相关状态
|
||||||
const statusFilter = ref('all') // 'all' | 'active' | 'error'
|
const statusFilter = ref('all') // 'all' | 'active' | 'error'
|
||||||
@@ -703,14 +720,71 @@ const exportKeys = (type) => {
|
|||||||
showToast(`成功导出 ${keysToExport.length} 个 API Key`, 'success')
|
showToast(`成功导出 ${keysToExport.length} 个 API Key`, 'success')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 写入剪贴板(带回退逻辑)
|
||||||
|
const writeToClipboard = async (text) => {
|
||||||
|
const canUseClipboardApi =
|
||||||
|
typeof navigator !== 'undefined' &&
|
||||||
|
navigator.clipboard &&
|
||||||
|
typeof navigator.clipboard.writeText === 'function' &&
|
||||||
|
(typeof window === 'undefined' || window.isSecureContext !== false)
|
||||||
|
|
||||||
|
if (canUseClipboardApi) {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
throw new Error('clipboard unavailable')
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = text
|
||||||
|
textarea.setAttribute('readonly', '')
|
||||||
|
textarea.style.position = 'fixed'
|
||||||
|
textarea.style.opacity = '0'
|
||||||
|
textarea.style.pointerEvents = 'none'
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = document.execCommand('copy')
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
if (!success) {
|
||||||
|
throw new Error('execCommand failed')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 复制 API Key
|
// 复制 API Key
|
||||||
const copyApiKey = async (key) => {
|
const copyApiKey = async (key) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(key)
|
await writeToClipboard(key)
|
||||||
showToast('API Key 已复制', 'success')
|
showToast('API Key 已复制', 'success')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to copy:', error)
|
console.error('Failed to copy:', error)
|
||||||
showToast('复制失败', 'error')
|
showToast('复制失败,请手动复制', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制全部 API Key
|
||||||
|
const copyAllApiKeys = async () => {
|
||||||
|
if (!apiKeys.value.length || copyingAll.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
copyingAll.value = true
|
||||||
|
try {
|
||||||
|
const allKeysText = apiKeys.value.map((item) => item.key).join('\n')
|
||||||
|
await writeToClipboard(allKeysText)
|
||||||
|
showToast(`已复制 ${apiKeys.value.length} 条 API Key`, 'success')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy all keys:', error)
|
||||||
|
showToast('复制全部 API Key 失败,请手动复制', 'error')
|
||||||
|
} finally {
|
||||||
|
copyingAll.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3728,47 +3728,54 @@ const closeAccountExpiryEdit = () => {
|
|||||||
editingExpiryAccount.value = null
|
editingExpiryAccount.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据账户平台解析更新端点
|
|
||||||
const resolveAccountUpdateEndpoint = (account) => {
|
|
||||||
switch (account.platform) {
|
|
||||||
case 'claude':
|
|
||||||
return `/admin/claude-accounts/${account.id}`
|
|
||||||
case 'claude-console':
|
|
||||||
return `/admin/claude-console-accounts/${account.id}`
|
|
||||||
case 'bedrock':
|
|
||||||
return `/admin/bedrock-accounts/${account.id}`
|
|
||||||
case 'openai':
|
|
||||||
return `/admin/openai-accounts/${account.id}`
|
|
||||||
case 'azure_openai':
|
|
||||||
return `/admin/azure-openai-accounts/${account.id}`
|
|
||||||
case 'openai-responses':
|
|
||||||
return `/admin/openai-responses-accounts/${account.id}`
|
|
||||||
case 'ccr':
|
|
||||||
return `/admin/ccr-accounts/${account.id}`
|
|
||||||
case 'gemini':
|
|
||||||
return `/admin/gemini-accounts/${account.id}`
|
|
||||||
case 'droid':
|
|
||||||
return `/admin/droid-accounts/${account.id}`
|
|
||||||
default:
|
|
||||||
throw new Error(`Unsupported platform: ${account.platform}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存账户过期时间
|
// 保存账户过期时间
|
||||||
const handleSaveAccountExpiry = async ({ accountId, expiresAt }) => {
|
const handleSaveAccountExpiry = async ({ accountId, expiresAt }) => {
|
||||||
try {
|
try {
|
||||||
// 找到对应的账户以获取平台信息
|
// 根据账号平台选择正确的 API 端点
|
||||||
const account = accounts.value.find((acc) => acc.id === accountId)
|
const account = accounts.value.find((acc) => acc.id === accountId)
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
showToast('账户不存在', 'error')
|
showToast('未找到账户', 'error')
|
||||||
if (expiryEditModalRef.value) {
|
|
||||||
expiryEditModalRef.value.resetSaving()
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据平台动态选择端点
|
// 定义每个平台的端点和参数名
|
||||||
const endpoint = resolveAccountUpdateEndpoint(account)
|
// 注意:部分平台使用 :accountId,部分使用 :id
|
||||||
|
let endpoint = ''
|
||||||
|
switch (account.platform) {
|
||||||
|
case 'claude':
|
||||||
|
case 'claude-oauth':
|
||||||
|
endpoint = `/admin/claude-accounts/${accountId}`
|
||||||
|
break
|
||||||
|
case 'gemini':
|
||||||
|
endpoint = `/admin/gemini-accounts/${accountId}`
|
||||||
|
break
|
||||||
|
case 'claude-console':
|
||||||
|
endpoint = `/admin/claude-console-accounts/${accountId}`
|
||||||
|
break
|
||||||
|
case 'bedrock':
|
||||||
|
endpoint = `/admin/bedrock-accounts/${accountId}`
|
||||||
|
break
|
||||||
|
case 'ccr':
|
||||||
|
endpoint = `/admin/ccr-accounts/${accountId}`
|
||||||
|
break
|
||||||
|
case 'openai':
|
||||||
|
endpoint = `/admin/openai-accounts/${accountId}` // 使用 :id
|
||||||
|
break
|
||||||
|
case 'droid':
|
||||||
|
endpoint = `/admin/droid-accounts/${accountId}` // 使用 :id
|
||||||
|
break
|
||||||
|
case 'azure_openai':
|
||||||
|
endpoint = `/admin/azure-openai-accounts/${accountId}` // 使用 :id
|
||||||
|
break
|
||||||
|
case 'openai-responses':
|
||||||
|
endpoint = `/admin/openai-responses-accounts/${accountId}` // 使用 :id
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
showToast(`不支持的平台类型: ${account.platform}`, 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const data = await apiClient.put(endpoint, {
|
const data = await apiClient.put(endpoint, {
|
||||||
expiresAt: expiresAt || null
|
expiresAt: expiresAt || null
|
||||||
})
|
})
|
||||||
@@ -3786,7 +3793,8 @@ const handleSaveAccountExpiry = async ({ accountId, expiresAt }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error.message || '更新失败', 'error')
|
console.error('更新账户过期时间失败:', error)
|
||||||
|
showToast('更新失败', 'error')
|
||||||
// 重置保存状态
|
// 重置保存状态
|
||||||
if (expiryEditModalRef.value) {
|
if (expiryEditModalRef.value) {
|
||||||
expiryEditModalRef.value.resetSaving()
|
expiryEditModalRef.value.resetSaving()
|
||||||
@@ -3802,6 +3810,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.table-container {
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
@@ -3835,6 +3844,12 @@ onMounted(() => {
|
|||||||
min-height: calc(100vh - 300px);
|
min-height: calc(100vh - 300px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
.table-row {
|
.table-row {
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user