Merge pull request #821 from guoyongchang/feat/cron-test-support

feat: Claude账户定时测试功能
This commit is contained in:
Wesley Liddick
2025-12-19 02:50:08 -05:00
committed by GitHub
10 changed files with 1607 additions and 27 deletions

18
package-lock.json generated
View File

@@ -26,6 +26,7 @@
"ioredis": "^5.3.2",
"ldapjs": "^3.0.7",
"morgan": "^1.10.0",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.6",
"ora": "^5.4.1",
"rate-limiter-flexible": "^5.0.5",
@@ -891,7 +892,6 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -3000,7 +3000,6 @@
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -3082,7 +3081,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3538,7 +3536,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211",
@@ -4426,7 +4423,6 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4483,7 +4479,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -7034,6 +7029,15 @@
"node": ">= 0.6"
}
},
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmmirror.com/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -7582,7 +7586,6 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -9101,7 +9104,6 @@
"resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz",
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.2",

View File

@@ -65,6 +65,7 @@
"ioredis": "^5.3.2",
"ldapjs": "^3.0.7",
"morgan": "^1.10.0",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.6",
"ora": "^5.4.1",
"rate-limiter-flexible": "^5.0.5",

71
pnpm-lock.yaml generated
View File

@@ -59,6 +59,9 @@ importers:
morgan:
specifier: ^1.10.0
version: 1.10.1
node-cron:
specifier: ^4.2.1
version: 4.2.1
nodemailer:
specifier: ^7.0.6
version: 7.0.11
@@ -108,6 +111,9 @@ importers:
prettier:
specifier: ^3.6.2
version: 3.7.4
prettier-plugin-tailwindcss:
specifier: ^0.7.2
version: 0.7.2(prettier@3.7.4)
supertest:
specifier: ^6.3.3
version: 6.3.4
@@ -2144,6 +2150,10 @@ packages:
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
engines: {node: '>= 0.6'}
node-cron@4.2.1:
resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
engines: {node: '>=6.0.0'}
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
@@ -2302,6 +2312,61 @@ packages:
resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==}
engines: {node: '>=6.0.0'}
prettier-plugin-tailwindcss@0.7.2:
resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==}
engines: {node: '>=20.19'}
peerDependencies:
'@ianvs/prettier-plugin-sort-imports': '*'
'@prettier/plugin-hermes': '*'
'@prettier/plugin-oxc': '*'
'@prettier/plugin-pug': '*'
'@shopify/prettier-plugin-liquid': '*'
'@trivago/prettier-plugin-sort-imports': '*'
'@zackad/prettier-plugin-twig': '*'
prettier: ^3.0
prettier-plugin-astro: '*'
prettier-plugin-css-order: '*'
prettier-plugin-jsdoc: '*'
prettier-plugin-marko: '*'
prettier-plugin-multiline-arrays: '*'
prettier-plugin-organize-attributes: '*'
prettier-plugin-organize-imports: '*'
prettier-plugin-sort-imports: '*'
prettier-plugin-svelte: '*'
peerDependenciesMeta:
'@ianvs/prettier-plugin-sort-imports':
optional: true
'@prettier/plugin-hermes':
optional: true
'@prettier/plugin-oxc':
optional: true
'@prettier/plugin-pug':
optional: true
'@shopify/prettier-plugin-liquid':
optional: true
'@trivago/prettier-plugin-sort-imports':
optional: true
'@zackad/prettier-plugin-twig':
optional: true
prettier-plugin-astro:
optional: true
prettier-plugin-css-order:
optional: true
prettier-plugin-jsdoc:
optional: true
prettier-plugin-marko:
optional: true
prettier-plugin-multiline-arrays:
optional: true
prettier-plugin-organize-attributes:
optional: true
prettier-plugin-organize-imports:
optional: true
prettier-plugin-sort-imports:
optional: true
prettier-plugin-svelte:
optional: true
prettier@3.7.4:
resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==}
engines: {node: '>=14'}
@@ -5692,6 +5757,8 @@ snapshots:
negotiator@0.6.4: {}
node-cron@4.2.1: {}
node-domexception@1.0.0: {}
node-fetch@3.3.2:
@@ -5840,6 +5907,10 @@ snapshots:
dependencies:
fast-diff: 1.3.0
prettier-plugin-tailwindcss@0.7.2(prettier@3.7.4):
dependencies:
prettier: 3.7.4
prettier@3.7.4: {}
pretty-format@29.7.0:

View File

@@ -661,6 +661,19 @@ class Application {
'🚦 Skipping concurrency queue cleanup on startup (CLEAR_CONCURRENCY_QUEUES_ON_STARTUP=false)'
)
}
// 🧪 启动账户定时测试调度器
// 根据配置定期测试账户连通性并保存测试历史
const accountTestSchedulerEnabled =
process.env.ACCOUNT_TEST_SCHEDULER_ENABLED !== 'false' &&
config.accountTestScheduler?.enabled !== false
if (accountTestSchedulerEnabled) {
const accountTestSchedulerService = require('./services/accountTestSchedulerService')
accountTestSchedulerService.start()
logger.info('🧪 Account test scheduler service started')
} else {
logger.info('🧪 Account test scheduler service disabled')
}
}
setupGracefulShutdown() {
@@ -715,6 +728,15 @@ class Application {
logger.error('❌ Error stopping cost rank service:', error)
}
// 停止账户定时测试调度器
try {
const accountTestSchedulerService = require('./services/accountTestSchedulerService')
accountTestSchedulerService.stop()
logger.info('🧪 Account test scheduler service stopped')
} catch (error) {
logger.error('❌ Error stopping account test scheduler service:', error)
}
// 🔢 清理所有并发计数Phase 1 修复:防止重启泄漏)
try {
logger.info('🔢 Cleaning up all concurrency counters...')

View File

@@ -96,7 +96,25 @@ class RedisClient {
logger.warn('⚠️ Redis connection closed')
})
await this.client.connect()
// 只有在 lazyConnect 模式下才需要手动调用 connect()
// 如果 Redis 已经连接或正在连接中,则跳过
if (
this.client.status !== 'connecting' &&
this.client.status !== 'connect' &&
this.client.status !== 'ready'
) {
await this.client.connect()
} else {
// 等待 ready 状态
await new Promise((resolve, reject) => {
if (this.client.status === 'ready') {
resolve()
} else {
this.client.once('ready', resolve)
this.client.once('error', reject)
}
})
}
return this.client
} catch (error) {
logger.error('💥 Failed to connect to Redis:', error)
@@ -3157,4 +3175,249 @@ redisClient.scanConcurrencyQueueStatsKeys = async function () {
}
}
// ============================================================================
// 账户测试历史相关操作
// ============================================================================
const ACCOUNT_TEST_HISTORY_MAX = 5 // 保留最近5次测试记录
const ACCOUNT_TEST_HISTORY_TTL = 86400 * 30 // 30天过期
const ACCOUNT_TEST_CONFIG_TTL = 86400 * 365 // 测试配置保留1年用户通常长期使用
/**
* 保存账户测试结果
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型 (claude/gemini/openai等)
* @param {Object} testResult - 测试结果对象
* @param {boolean} testResult.success - 是否成功
* @param {string} testResult.message - 测试消息/响应
* @param {number} testResult.latencyMs - 延迟毫秒数
* @param {string} testResult.error - 错误信息(如有)
* @param {string} testResult.timestamp - 测试时间戳
*/
redisClient.saveAccountTestResult = async function (accountId, platform, testResult) {
const key = `account:test_history:${platform}:${accountId}`
try {
const record = JSON.stringify({
...testResult,
timestamp: testResult.timestamp || new Date().toISOString()
})
// 使用 LPUSH + LTRIM 保持最近5条记录
const client = this.getClientSafe()
await client.lpush(key, record)
await client.ltrim(key, 0, ACCOUNT_TEST_HISTORY_MAX - 1)
await client.expire(key, ACCOUNT_TEST_HISTORY_TTL)
logger.debug(`📝 Saved test result for ${platform} account ${accountId}`)
} catch (error) {
logger.error(`Failed to save test result for ${accountId}:`, error)
}
}
/**
* 获取账户测试历史
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @returns {Promise<Array>} 测试历史记录数组(最新在前)
*/
redisClient.getAccountTestHistory = async function (accountId, platform) {
const key = `account:test_history:${platform}:${accountId}`
try {
const client = this.getClientSafe()
const records = await client.lrange(key, 0, -1)
return records.map((r) => JSON.parse(r))
} catch (error) {
logger.error(`Failed to get test history for ${accountId}:`, error)
return []
}
}
/**
* 获取账户最新测试结果
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @returns {Promise<Object|null>} 最新测试结果
*/
redisClient.getAccountLatestTestResult = async function (accountId, platform) {
const key = `account:test_history:${platform}:${accountId}`
try {
const client = this.getClientSafe()
const record = await client.lindex(key, 0)
return record ? JSON.parse(record) : null
} catch (error) {
logger.error(`Failed to get latest test result for ${accountId}:`, error)
return null
}
}
/**
* 批量获取多个账户的测试历史
* @param {Array<{accountId: string, platform: string}>} accounts - 账户列表
* @returns {Promise<Object>} 以 accountId 为 key 的测试历史映射
*/
redisClient.getAccountsTestHistory = async function (accounts) {
const result = {}
try {
const client = this.getClientSafe()
const pipeline = client.pipeline()
for (const { accountId, platform } of accounts) {
const key = `account:test_history:${platform}:${accountId}`
pipeline.lrange(key, 0, -1)
}
const responses = await pipeline.exec()
accounts.forEach(({ accountId }, index) => {
const [err, records] = responses[index]
if (!err && records) {
result[accountId] = records.map((r) => JSON.parse(r))
} else {
result[accountId] = []
}
})
} catch (error) {
logger.error('Failed to get batch test history:', error)
}
return result
}
/**
* 保存定时测试配置
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @param {Object} config - 配置对象
* @param {boolean} config.enabled - 是否启用定时测试
* @param {string} config.cronExpression - Cron 表达式 (如 "0 8 * * *" 表示每天8点)
* @param {string} config.model - 测试使用的模型
*/
redisClient.saveAccountTestConfig = async function (accountId, platform, testConfig) {
const key = `account:test_config:${platform}:${accountId}`
try {
const client = this.getClientSafe()
await client.hset(key, {
enabled: testConfig.enabled ? 'true' : 'false',
cronExpression: testConfig.cronExpression || '0 8 * * *', // 默认每天早上8点
model: testConfig.model || 'claude-sonnet-4-5-20250929', // 默认模型
updatedAt: new Date().toISOString()
})
// 设置过期时间1年
await client.expire(key, ACCOUNT_TEST_CONFIG_TTL)
} catch (error) {
logger.error(`Failed to save test config for ${accountId}:`, error)
}
}
/**
* 获取定时测试配置
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @returns {Promise<Object|null>} 配置对象
*/
redisClient.getAccountTestConfig = async function (accountId, platform) {
const key = `account:test_config:${platform}:${accountId}`
try {
const client = this.getClientSafe()
const testConfig = await client.hgetall(key)
if (!testConfig || Object.keys(testConfig).length === 0) {
return null
}
// 向后兼容:如果存在旧的 testHour 字段,转换为 cron 表达式
let { cronExpression } = testConfig
if (!cronExpression && testConfig.testHour) {
const hour = parseInt(testConfig.testHour, 10)
cronExpression = `0 ${hour} * * *`
}
return {
enabled: testConfig.enabled === 'true',
cronExpression: cronExpression || '0 8 * * *',
model: testConfig.model || 'claude-sonnet-4-5-20250929',
updatedAt: testConfig.updatedAt
}
} catch (error) {
logger.error(`Failed to get test config for ${accountId}:`, error)
return null
}
}
/**
* 获取所有启用定时测试的账户
* @param {string} platform - 平台类型
* @returns {Promise<Array>} 账户ID列表及 cron 配置
*/
redisClient.getEnabledTestAccounts = async function (platform) {
const accountIds = []
let cursor = '0'
try {
const client = this.getClientSafe()
do {
const [newCursor, keys] = await client.scan(
cursor,
'MATCH',
`account:test_config:${platform}:*`,
'COUNT',
100
)
cursor = newCursor
for (const key of keys) {
const testConfig = await client.hgetall(key)
if (testConfig && testConfig.enabled === 'true') {
const accountId = key.replace(`account:test_config:${platform}:`, '')
// 向后兼容:如果存在旧的 testHour 字段,转换为 cron 表达式
let { cronExpression } = testConfig
if (!cronExpression && testConfig.testHour) {
const hour = parseInt(testConfig.testHour, 10)
cronExpression = `0 ${hour} * * *`
}
accountIds.push({
accountId,
cronExpression: cronExpression || '0 8 * * *',
model: testConfig.model || 'claude-sonnet-4-5-20250929'
})
}
}
} while (cursor !== '0')
return accountIds
} catch (error) {
logger.error(`Failed to get enabled test accounts for ${platform}:`, error)
return []
}
}
/**
* 保存账户上次测试时间(用于调度器判断是否需要测试)
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
*/
redisClient.setAccountLastTestTime = async function (accountId, platform) {
const key = `account:last_test:${platform}:${accountId}`
try {
const client = this.getClientSafe()
await client.set(key, Date.now().toString(), 'EX', 86400 * 7) // 7天过期
} catch (error) {
logger.error(`Failed to set last test time for ${accountId}:`, error)
}
}
/**
* 获取账户上次测试时间
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @returns {Promise<number|null>} 上次测试时间戳
*/
redisClient.getAccountLastTestTime = async function (accountId, platform) {
const key = `account:last_test:${platform}:${accountId}`
try {
const client = this.getClientSafe()
const timestamp = await client.get(key)
return timestamp ? parseInt(timestamp, 10) : null
} catch (error) {
logger.error(`Failed to get last test time for ${accountId}:`, error)
return null
}
}
module.exports = redisClient

View File

@@ -9,6 +9,7 @@ const router = express.Router()
const claudeAccountService = require('../../services/claudeAccountService')
const claudeRelayService = require('../../services/claudeRelayService')
const accountGroupService = require('../../services/accountGroupService')
const accountTestSchedulerService = require('../../services/accountTestSchedulerService')
const apiKeyService = require('../../services/apiKeyService')
const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth')
@@ -277,7 +278,7 @@ router.post('/claude-accounts/oauth-with-cookie', authenticateAdmin, async (req,
logger.info('🍪 Starting Cookie-based OAuth authorization', {
sessionKeyLength: trimmedSessionKey.length,
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
sessionKeyPrefix: `${trimmedSessionKey.substring(0, 10)}...`,
hasProxy: !!proxy
})
@@ -326,7 +327,7 @@ router.post('/claude-accounts/setup-token-with-cookie', authenticateAdmin, async
logger.info('🍪 Starting Cookie-based Setup Token authorization', {
sessionKeyLength: trimmedSessionKey.length,
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
sessionKeyPrefix: `${trimmedSessionKey.substring(0, 10)}...`,
hasProxy: !!proxy
})
@@ -903,4 +904,219 @@ router.post('/claude-accounts/:accountId/test', authenticateAdmin, async (req, r
}
})
// ============================================================================
// 账户定时测试相关端点
// ============================================================================
// 获取账户测试历史
router.get('/claude-accounts/:accountId/test-history', authenticateAdmin, async (req, res) => {
const { accountId } = req.params
try {
const history = await redis.getAccountTestHistory(accountId, 'claude')
return res.json({
success: true,
data: {
accountId,
platform: 'claude',
history
}
})
} catch (error) {
logger.error(`❌ Failed to get test history for account ${accountId}:`, error)
return res.status(500).json({
error: 'Failed to get test history',
message: error.message
})
}
})
// 获取账户定时测试配置
router.get('/claude-accounts/:accountId/test-config', authenticateAdmin, async (req, res) => {
const { accountId } = req.params
try {
const testConfig = await redis.getAccountTestConfig(accountId, 'claude')
return res.json({
success: true,
data: {
accountId,
platform: 'claude',
config: testConfig || {
enabled: false,
cronExpression: '0 8 * * *',
model: 'claude-sonnet-4-5-20250929'
}
}
})
} catch (error) {
logger.error(`❌ Failed to get test config for account ${accountId}:`, error)
return res.status(500).json({
error: 'Failed to get test config',
message: error.message
})
}
})
// 设置账户定时测试配置
router.put('/claude-accounts/:accountId/test-config', authenticateAdmin, async (req, res) => {
const { accountId } = req.params
const { enabled, cronExpression, model } = req.body
try {
// 验证 enabled 参数
if (typeof enabled !== 'boolean') {
return res.status(400).json({
error: 'Invalid parameter',
message: 'enabled must be a boolean'
})
}
// 验证 cronExpression 参数
if (!cronExpression || typeof cronExpression !== 'string') {
return res.status(400).json({
error: 'Invalid parameter',
message: 'cronExpression is required and must be a string'
})
}
// 限制 cronExpression 长度防止 DoS
const MAX_CRON_LENGTH = 100
if (cronExpression.length > MAX_CRON_LENGTH) {
return res.status(400).json({
error: 'Invalid parameter',
message: `cronExpression too long (max ${MAX_CRON_LENGTH} characters)`
})
}
// 使用 service 的方法验证 cron 表达式
if (!accountTestSchedulerService.validateCronExpression(cronExpression)) {
return res.status(400).json({
error: 'Invalid parameter',
message: `Invalid cron expression: ${cronExpression}. Format: "minute hour day month weekday" (e.g., "0 8 * * *" for daily at 8:00)`
})
}
// 验证模型参数
const testModel = model || 'claude-sonnet-4-5-20250929'
if (typeof testModel !== 'string' || testModel.length > 256) {
return res.status(400).json({
error: 'Invalid parameter',
message: 'model must be a valid string (max 256 characters)'
})
}
// 检查账户是否存在
const account = await claudeAccountService.getAccount(accountId)
if (!account) {
return res.status(404).json({
error: 'Account not found',
message: `Claude account ${accountId} not found`
})
}
// 保存配置
await redis.saveAccountTestConfig(accountId, 'claude', {
enabled,
cronExpression,
model: testModel
})
logger.success(
`📝 Updated test config for Claude account ${accountId}: enabled=${enabled}, cronExpression=${cronExpression}, model=${testModel}`
)
return res.json({
success: true,
message: 'Test config updated successfully',
data: {
accountId,
platform: 'claude',
config: { enabled, cronExpression, model: testModel }
}
})
} catch (error) {
logger.error(`❌ Failed to update test config for account ${accountId}:`, error)
return res.status(500).json({
error: 'Failed to update test config',
message: error.message
})
}
})
// 手动触发账户测试非流式返回JSON结果
router.post('/claude-accounts/:accountId/test-sync', authenticateAdmin, async (req, res) => {
const { accountId } = req.params
try {
// 检查账户是否存在
const account = await claudeAccountService.getAccount(accountId)
if (!account) {
return res.status(404).json({
error: 'Account not found',
message: `Claude account ${accountId} not found`
})
}
logger.info(`🧪 Manual sync test triggered for Claude account: ${accountId}`)
// 执行测试
const testResult = await claudeRelayService.testAccountConnectionSync(accountId)
// 保存测试结果到历史
await redis.saveAccountTestResult(accountId, 'claude', testResult)
await redis.setAccountLastTestTime(accountId, 'claude')
return res.json({
success: true,
data: {
accountId,
platform: 'claude',
result: testResult
}
})
} catch (error) {
logger.error(`❌ Failed to run sync test for account ${accountId}:`, error)
return res.status(500).json({
error: 'Failed to run test',
message: error.message
})
}
})
// 批量获取多个账户的测试历史
router.post('/claude-accounts/batch-test-history', authenticateAdmin, async (req, res) => {
const { accountIds } = req.body
try {
if (!Array.isArray(accountIds) || accountIds.length === 0) {
return res.status(400).json({
error: 'Invalid parameter',
message: 'accountIds must be a non-empty array'
})
}
// 限制批量查询数量
const limitedIds = accountIds.slice(0, 100)
const accounts = limitedIds.map((accountId) => ({
accountId,
platform: 'claude'
}))
const historyMap = await redis.getAccountsTestHistory(accounts)
return res.json({
success: true,
data: historyMap
})
} catch (error) {
logger.error('❌ Failed to get batch test history:', error)
return res.status(500).json({
error: 'Failed to get batch test history',
message: error.message
})
}
})
module.exports = router

View File

@@ -0,0 +1,420 @@
/**
* 账户定时测试调度服务
* 使用 node-cron 支持 crontab 表达式,为每个账户创建独立的定时任务
*/
const cron = require('node-cron')
const redis = require('../models/redis')
const logger = require('../utils/logger')
class AccountTestSchedulerService {
constructor() {
// 存储每个账户的 cron 任务: Map<string, { task: ScheduledTask, cronExpression: string }>
this.scheduledTasks = new Map()
// 定期刷新配置的间隔 (毫秒)
this.refreshIntervalMs = 60 * 1000
this.refreshInterval = null
// 当前正在测试的账户
this.testingAccounts = new Set()
// 是否已启动
this.isStarted = false
}
/**
* 验证 cron 表达式是否有效
* @param {string} cronExpression - cron 表达式
* @returns {boolean}
*/
validateCronExpression(cronExpression) {
// 长度检查(防止 DoS
if (!cronExpression || cronExpression.length > 100) {
return false
}
return cron.validate(cronExpression)
}
/**
* 启动调度器
*/
async start() {
if (this.isStarted) {
logger.warn('⚠️ Account test scheduler is already running')
return
}
this.isStarted = true
logger.info('🚀 Starting account test scheduler service (node-cron mode)')
// 初始化所有已配置账户的定时任务
await this._refreshAllTasks()
// 定期刷新配置,以便动态添加/修改的配置能生效
this.refreshInterval = setInterval(() => {
this._refreshAllTasks()
}, this.refreshIntervalMs)
logger.info(
`📅 Account test scheduler started (refreshing configs every ${this.refreshIntervalMs / 1000}s)`
)
}
/**
* 停止调度器
*/
stop() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval)
this.refreshInterval = null
}
// 停止所有 cron 任务
for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) {
taskInfo.task.stop()
logger.debug(`🛑 Stopped cron task for ${accountKey}`)
}
this.scheduledTasks.clear()
this.isStarted = false
logger.info('🛑 Account test scheduler stopped')
}
/**
* 刷新所有账户的定时任务
* @private
*/
async _refreshAllTasks() {
try {
const platforms = ['claude', 'gemini', 'openai']
const activeAccountKeys = new Set()
// 并行加载所有平台的配置
const allEnabledAccounts = await Promise.all(
platforms.map((platform) =>
redis
.getEnabledTestAccounts(platform)
.then((accounts) => accounts.map((acc) => ({ ...acc, platform })))
.catch((error) => {
logger.warn(`⚠️ Failed to load test accounts for platform ${platform}:`, error)
return []
})
)
)
// 展平平台数据
const flatAccounts = allEnabledAccounts.flat()
for (const { accountId, cronExpression, model, platform } of flatAccounts) {
if (!cronExpression) {
logger.warn(
`⚠️ Account ${accountId} (${platform}) has no valid cron expression, skipping`
)
continue
}
const accountKey = `${platform}:${accountId}`
activeAccountKeys.add(accountKey)
// 检查是否需要更新任务
const existingTask = this.scheduledTasks.get(accountKey)
if (existingTask) {
// 如果 cron 表达式和模型都没变,不需要更新
if (existingTask.cronExpression === cronExpression && existingTask.model === model) {
continue
}
// 配置变了,停止旧任务
existingTask.task.stop()
logger.info(`🔄 Updating cron task for ${accountKey}: ${cronExpression}, model: ${model}`)
} else {
logger.info(` Creating cron task for ${accountKey}: ${cronExpression}, model: ${model}`)
}
// 创建新的 cron 任务
this._createCronTask(accountId, platform, cronExpression, model)
}
// 清理已删除或禁用的账户任务
for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) {
if (!activeAccountKeys.has(accountKey)) {
taskInfo.task.stop()
this.scheduledTasks.delete(accountKey)
logger.info(` Removed cron task for ${accountKey} (disabled or deleted)`)
}
}
} catch (error) {
logger.error('❌ Error refreshing account test tasks:', error)
}
}
/**
* 为单个账户创建 cron 任务
* @param {string} accountId
* @param {string} platform
* @param {string} cronExpression
* @param {string} model - 测试使用的模型
* @private
*/
_createCronTask(accountId, platform, cronExpression, model) {
const accountKey = `${platform}:${accountId}`
// 验证 cron 表达式
if (!this.validateCronExpression(cronExpression)) {
logger.error(`❌ Invalid cron expression for ${accountKey}: ${cronExpression}`)
return
}
const task = cron.schedule(
cronExpression,
async () => {
await this._runAccountTest(accountId, platform, model)
},
{
scheduled: true,
timezone: process.env.TZ || 'Asia/Shanghai'
}
)
this.scheduledTasks.set(accountKey, {
task,
cronExpression,
model,
accountId,
platform
})
}
/**
* 执行单个账户测试
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @param {string} model - 测试使用的模型
* @private
*/
async _runAccountTest(accountId, platform, model) {
const accountKey = `${platform}:${accountId}`
// 避免重复测试
if (this.testingAccounts.has(accountKey)) {
logger.debug(`⏳ Account ${accountKey} is already being tested, skipping`)
return
}
this.testingAccounts.add(accountKey)
try {
logger.info(
`🧪 Running scheduled test for ${platform} account: ${accountId} (model: ${model})`
)
let testResult
// 根据平台调用对应的测试方法
switch (platform) {
case 'claude':
testResult = await this._testClaudeAccount(accountId, model)
break
case 'gemini':
testResult = await this._testGeminiAccount(accountId, model)
break
case 'openai':
testResult = await this._testOpenAIAccount(accountId, model)
break
default:
testResult = {
success: false,
error: `Unsupported platform: ${platform}`,
timestamp: new Date().toISOString()
}
}
// 保存测试结果
await redis.saveAccountTestResult(accountId, platform, testResult)
// 更新最后测试时间
await redis.setAccountLastTestTime(accountId, platform)
// 记录日志
if (testResult.success) {
logger.info(
`✅ Scheduled test passed for ${platform} account ${accountId} (${testResult.latencyMs}ms)`
)
} else {
logger.warn(
`❌ Scheduled test failed for ${platform} account ${accountId}: ${testResult.error}`
)
}
return testResult
} catch (error) {
logger.error(`❌ Error testing ${platform} account ${accountId}:`, error)
const errorResult = {
success: false,
error: error.message,
timestamp: new Date().toISOString()
}
await redis.saveAccountTestResult(accountId, platform, errorResult)
await redis.setAccountLastTestTime(accountId, platform)
return errorResult
} finally {
this.testingAccounts.delete(accountKey)
}
}
/**
* 测试 Claude 账户
* @param {string} accountId
* @param {string} model - 测试使用的模型
* @private
*/
async _testClaudeAccount(accountId, model) {
const claudeRelayService = require('./claudeRelayService')
return await claudeRelayService.testAccountConnectionSync(accountId, model)
}
/**
* 测试 Gemini 账户
* @param {string} _accountId
* @param {string} _model
* @private
*/
async _testGeminiAccount(_accountId, _model) {
// Gemini 测试暂时返回未实现
return {
success: false,
error: 'Gemini scheduled test not implemented yet',
timestamp: new Date().toISOString()
}
}
/**
* 测试 OpenAI 账户
* @param {string} _accountId
* @param {string} _model
* @private
*/
async _testOpenAIAccount(_accountId, _model) {
// OpenAI 测试暂时返回未实现
return {
success: false,
error: 'OpenAI scheduled test not implemented yet',
timestamp: new Date().toISOString()
}
}
/**
* 手动触发账户测试
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @param {string} model - 测试使用的模型
* @returns {Promise<Object>} 测试结果
*/
async triggerTest(accountId, platform, model = 'claude-sonnet-4-5-20250929') {
logger.info(`🎯 Manual test triggered for ${platform} account: ${accountId} (model: ${model})`)
return await this._runAccountTest(accountId, platform, model)
}
/**
* 获取账户测试历史
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @returns {Promise<Array>} 测试历史
*/
async getTestHistory(accountId, platform) {
return await redis.getAccountTestHistory(accountId, platform)
}
/**
* 获取账户测试配置
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @returns {Promise<Object|null>}
*/
async getTestConfig(accountId, platform) {
return await redis.getAccountTestConfig(accountId, platform)
}
/**
* 设置账户测试配置
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @param {Object} testConfig - 测试配置 { enabled: boolean, cronExpression: string, model: string }
* @returns {Promise<void>}
*/
async setTestConfig(accountId, platform, testConfig) {
// 验证 cron 表达式
if (testConfig.cronExpression && !this.validateCronExpression(testConfig.cronExpression)) {
throw new Error(`Invalid cron expression: ${testConfig.cronExpression}`)
}
await redis.saveAccountTestConfig(accountId, platform, testConfig)
logger.info(
`📝 Test config updated for ${platform} account ${accountId}: enabled=${testConfig.enabled}, cronExpression=${testConfig.cronExpression}, model=${testConfig.model}`
)
// 立即刷新任务,使配置立即生效
if (this.isStarted) {
await this._refreshAllTasks()
}
}
/**
* 更新单个账户的定时任务(配置变更时调用)
* @param {string} accountId
* @param {string} platform
*/
async refreshAccountTask(accountId, platform) {
if (!this.isStarted) {
return
}
const accountKey = `${platform}:${accountId}`
const testConfig = await redis.getAccountTestConfig(accountId, platform)
// 停止现有任务
const existingTask = this.scheduledTasks.get(accountKey)
if (existingTask) {
existingTask.task.stop()
this.scheduledTasks.delete(accountKey)
}
// 如果启用且有有效的 cron 表达式,创建新任务
if (testConfig?.enabled && testConfig?.cronExpression) {
this._createCronTask(accountId, platform, testConfig.cronExpression, testConfig.model)
logger.info(
`🔄 Refreshed cron task for ${accountKey}: ${testConfig.cronExpression}, model: ${testConfig.model}`
)
}
}
/**
* 获取调度器状态
* @returns {Object}
*/
getStatus() {
const tasks = []
for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) {
tasks.push({
accountKey,
accountId: taskInfo.accountId,
platform: taskInfo.platform,
cronExpression: taskInfo.cronExpression,
model: taskInfo.model
})
}
return {
running: this.isStarted,
refreshIntervalMs: this.refreshIntervalMs,
scheduledTasksCount: this.scheduledTasks.size,
scheduledTasks: tasks,
currentlyTesting: Array.from(this.testingAccounts)
}
}
}
// 单例模式
const accountTestSchedulerService = new AccountTestSchedulerService()
module.exports = accountTestSchedulerService

View File

@@ -2456,28 +2456,35 @@ class ClaudeRelayService {
}
}
// 🔧 准备测试请求的公共逻辑(供 testAccountConnection 和 testAccountConnectionSync 共用)
async _prepareAccountForTest(accountId) {
// 获取账户信息
const account = await claudeAccountService.getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
// 获取有效的访问token
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
if (!accessToken) {
throw new Error('Failed to get valid access token')
}
// 获取代理配置
const proxyAgent = await this._getProxyAgent(accountId)
return { account, accessToken, proxyAgent }
}
// 🧪 测试账号连接供Admin API使用直接复用 _makeClaudeStreamRequestWithUsageCapture
async testAccountConnection(accountId, responseStream) {
const testRequestBody = createClaudeTestPayload('claude-sonnet-4-5-20250929', { stream: true })
async testAccountConnection(accountId, responseStream, model = 'claude-sonnet-4-5-20250929') {
const testRequestBody = createClaudeTestPayload(model, { stream: true })
try {
// 获取账户信息
const account = await claudeAccountService.getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
const { account, accessToken, proxyAgent } = await this._prepareAccountForTest(accountId)
logger.info(`🧪 Testing Claude account connection: ${account.name} (${accountId})`)
// 获取有效的访问token
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
if (!accessToken) {
throw new Error('Failed to get valid access token')
}
// 获取代理配置
const proxyAgent = await this._getProxyAgent(accountId)
// 设置响应头
if (!responseStream.headersSent) {
const existingConnection = responseStream.getHeader
@@ -2526,6 +2533,125 @@ class ClaudeRelayService {
}
}
// 🧪 非流式测试账号连接(供定时任务使用)
// 复用流式请求方法,收集结果后返回
async testAccountConnectionSync(accountId, model = 'claude-sonnet-4-5-20250929') {
const testRequestBody = createClaudeTestPayload(model, { stream: true })
const startTime = Date.now()
try {
// 使用公共方法准备测试所需的账户信息、token 和代理
const { account, accessToken, proxyAgent } = await this._prepareAccountForTest(accountId)
logger.info(`🧪 Testing Claude account connection (sync): ${account.name} (${accountId})`)
// 创建一个收集器来捕获流式响应
let responseText = ''
let capturedUsage = null
let capturedModel = model
let hasError = false
let errorMessage = ''
// 创建模拟的响应流对象
const mockResponseStream = {
headersSent: true, // 跳过设置响应头
write: (data) => {
// 解析 SSE 数据
if (typeof data === 'string' && data.startsWith('data: ')) {
try {
const jsonStr = data.replace('data: ', '').trim()
if (jsonStr && jsonStr !== '[DONE]') {
const parsed = JSON.parse(jsonStr)
// 提取文本内容
if (parsed.type === 'content_block_delta' && parsed.delta?.text) {
responseText += parsed.delta.text
}
// 提取 usage 信息
if (parsed.type === 'message_delta' && parsed.usage) {
capturedUsage = parsed.usage
}
// 提取模型信息
if (parsed.type === 'message_start' && parsed.message?.model) {
capturedModel = parsed.message.model
}
// 检测错误
if (parsed.type === 'error') {
hasError = true
errorMessage = parsed.error?.message || 'Unknown error'
}
}
} catch {
// 忽略解析错误
}
}
return true
},
end: () => {},
on: () => {},
once: () => {},
emit: () => {},
writable: true
}
// 复用流式请求方法
await this._makeClaudeStreamRequestWithUsageCapture(
testRequestBody,
accessToken,
proxyAgent,
{}, // clientHeaders - 测试不需要客户端headers
mockResponseStream,
null, // usageCallback - 测试不需要统计
accountId,
'claude-official', // accountType
null, // sessionHash - 测试不需要会话
null, // streamTransformer - 不需要转换,直接解析原始格式
{}, // requestOptions
false // isDedicatedOfficialAccount
)
const latencyMs = Date.now() - startTime
if (hasError) {
logger.warn(`⚠️ Test completed with error for account: ${account.name} - ${errorMessage}`)
return {
success: false,
error: errorMessage,
latencyMs,
timestamp: new Date().toISOString()
}
}
logger.info(`✅ Test completed for account: ${account.name} (${latencyMs}ms)`)
return {
success: true,
message: responseText.substring(0, 200), // 截取前200字符
latencyMs,
model: capturedModel,
usage: capturedUsage,
timestamp: new Date().toISOString()
}
} catch (error) {
const latencyMs = Date.now() - startTime
logger.error(`❌ Test account connection (sync) failed:`, error.message)
// 提取错误详情
let errorMessage = error.message
if (error.response) {
errorMessage =
error.response.data?.error?.message || error.response.statusText || error.message
}
return {
success: false,
error: errorMessage,
statusCode: error.response?.status,
latencyMs,
timestamp: new Date().toISOString()
}
}
}
// 🎯 健康检查
async healthCheck() {
try {

View File

@@ -0,0 +1,402 @@
<template>
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 z-[1050] flex items-center justify-center bg-gray-900/40 backdrop-blur-sm"
>
<div class="absolute inset-0" @click="handleClose" />
<div
class="relative z-10 mx-3 flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-gray-200/70 bg-white/95 shadow-2xl ring-1 ring-black/5 transition-all dark:border-gray-700/60 dark:bg-gray-900/95 dark:ring-white/10 sm:mx-4"
>
<!-- 顶部栏 -->
<div
class="flex items-center justify-between border-b border-gray-100 bg-white/80 px-5 py-4 backdrop-blur dark:border-gray-800 dark:bg-gray-900/80"
>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-amber-500 to-orange-500 text-white shadow-lg"
>
<i class="fas fa-clock" />
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">定时测试配置</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ account?.name || '未知账户' }}
</p>
</div>
</div>
<button
class="flex h-9 w-9 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
:disabled="saving"
@click="handleClose"
>
<i class="fas fa-times text-sm" />
</button>
</div>
<!-- 内容区域 -->
<div class="px-5 py-4">
<!-- 加载状态 -->
<div v-if="loading" class="flex items-center justify-center py-8">
<i class="fas fa-spinner fa-spin mr-2 text-blue-500" />
<span class="text-gray-500 dark:text-gray-400">加载配置中...</span>
</div>
<template v-else>
<!-- 启用开关 -->
<div class="mb-5 flex items-center justify-between">
<div>
<p class="font-medium text-gray-700 dark:text-gray-300">启用定时测试</p>
<p class="text-xs text-gray-500 dark:text-gray-400">按计划自动测试账户连通性</p>
</div>
<button
:class="[
'relative h-6 w-11 rounded-full transition-colors duration-200',
config.enabled ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'
]"
@click="config.enabled = !config.enabled"
>
<span
:class="[
'absolute top-0.5 h-5 w-5 rounded-full bg-white shadow-md transition-transform duration-200',
config.enabled ? 'left-5' : 'left-0.5'
]"
/>
</button>
</div>
<!-- Cron 表达式配置 -->
<div class="mb-5">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Cron 表达式
</label>
<input
v-model="config.cronExpression"
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 placeholder-gray-400 transition focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500"
:disabled="!config.enabled"
placeholder="0 8 * * *"
type="text"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
格式: (: "0 8 * * *" = 每天8:00)
</p>
</div>
<!-- 快捷选项 -->
<div class="mb-5">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
快捷设置
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="preset in cronPresets"
:key="preset.value"
:class="[
'rounded-lg border px-3 py-1.5 text-xs font-medium transition',
config.cronExpression === preset.value
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-300'
: 'border-gray-200 bg-gray-50 text-gray-600 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700',
!config.enabled && 'cursor-not-allowed opacity-50'
]"
:disabled="!config.enabled"
@click="config.cronExpression = preset.value"
>
{{ preset.label }}
</button>
</div>
</div>
<!-- 测试模型选择 -->
<div class="mb-5">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
测试模型
</label>
<input
v-model="config.model"
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 placeholder-gray-400 transition focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500"
:disabled="!config.enabled"
placeholder="claude-sonnet-4-5-20250929"
type="text"
/>
<div class="mt-2 flex flex-wrap gap-2">
<button
v-for="modelOption in modelOptions"
:key="modelOption.value"
:class="[
'rounded-lg border px-3 py-1.5 text-xs font-medium transition',
config.model === modelOption.value
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-300'
: 'border-gray-200 bg-gray-50 text-gray-600 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700',
!config.enabled && 'cursor-not-allowed opacity-50'
]"
:disabled="!config.enabled"
@click="config.model = modelOption.value"
>
{{ modelOption.label }}
</button>
</div>
</div>
<!-- 测试历史 -->
<div v-if="testHistory.length > 0" class="mb-4">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
最近测试记录
</label>
<div
class="max-h-40 space-y-2 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50"
>
<div
v-for="(record, index) in testHistory"
:key="index"
class="flex items-center justify-between text-xs"
>
<div class="flex items-center gap-2">
<i
:class="[
'fas',
record.success
? 'fa-check-circle text-green-500'
: 'fa-times-circle text-red-500'
]"
/>
<span class="text-gray-600 dark:text-gray-400">
{{ formatTimestamp(record.timestamp) }}
</span>
</div>
<span v-if="record.latencyMs" class="text-gray-500 dark:text-gray-500">
{{ record.latencyMs }}ms
</span>
<span
v-else-if="record.error"
class="max-w-[150px] truncate text-red-500"
:title="record.error"
>
{{ record.error }}
</span>
</div>
</div>
</div>
<!-- 无历史记录 -->
<div
v-else
class="mb-4 rounded-lg border border-gray-200 bg-gray-50 p-4 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400"
>
<i class="fas fa-history mb-2 text-2xl text-gray-300 dark:text-gray-600" />
<p>暂无测试记录</p>
</div>
</template>
</div>
<!-- 底部操作栏 -->
<div
class="flex items-center justify-end gap-3 border-t border-gray-100 bg-gray-50/80 px-5 py-3 dark:border-gray-800 dark:bg-gray-900/50"
>
<button
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
:disabled="saving"
@click="handleClose"
>
取消
</button>
<button
:class="[
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium shadow-sm transition',
saving
? 'cursor-not-allowed bg-gray-200 text-gray-400 dark:bg-gray-700 dark:text-gray-500'
: 'bg-gradient-to-r from-blue-500 to-indigo-500 text-white hover:from-blue-600 hover:to-indigo-600 hover:shadow-md'
]"
:disabled="saving || loading"
@click="saveConfig"
>
<i :class="['fas', saving ? 'fa-spinner fa-spin' : 'fa-save']" />
{{ saving ? '保存中...' : '保存配置' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, watch } from 'vue'
import { API_PREFIX } from '@/config/api'
import { showToast } from '@/utils/toast'
const props = defineProps({
show: {
type: Boolean,
default: false
},
account: {
type: Object,
default: null
}
})
const emit = defineEmits(['close', 'saved'])
// 状态
const loading = ref(false)
const saving = ref(false)
const config = ref({
enabled: false,
cronExpression: '0 8 * * *',
model: 'claude-sonnet-4-5-20250929'
})
const testHistory = ref([])
// Cron 预设选项
const cronPresets = [
{ label: '每天 8:00', value: '0 8 * * *' },
{ label: '每天 12:00', value: '0 12 * * *' },
{ label: '每天 18:00', value: '0 18 * * *' },
{ label: '每6小时', value: '0 */6 * * *' },
{ label: '每12小时', value: '0 */12 * * *' },
{ label: '工作日 9:00', value: '0 9 * * 1-5' }
]
// 模型选项
const modelOptions = [
{ label: 'Claude Sonnet 4.5', value: 'claude-sonnet-4-5-20250929' },
{ label: 'Claude Haiku 4.5', value: 'claude-haiku-4-5-20251001' },
{ label: 'Claude Opus 4.5', value: 'claude-opus-4-5-20251101' }
]
// 格式化时间戳
function formatTimestamp(timestamp) {
if (!timestamp) return '未知'
const date = new Date(timestamp)
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 加载配置
async function loadConfig() {
if (!props.account) return
loading.value = true
try {
const authToken = localStorage.getItem('authToken')
const platform = props.account.platform
// 根据平台获取配置端点
let endpoint = ''
if (platform === 'claude') {
endpoint = `${API_PREFIX}/admin/claude-accounts/${props.account.id}/test-config`
} else {
// 其他平台暂不支持
loading.value = false
return
}
// 获取配置
const configRes = await fetch(endpoint, {
headers: {
Authorization: authToken ? `Bearer ${authToken}` : ''
}
})
if (configRes.ok) {
const data = await configRes.json()
if (data.success && data.data?.config) {
config.value = {
enabled: data.data.config.enabled || false,
cronExpression: data.data.config.cronExpression || '0 8 * * *',
model: data.data.config.model || 'claude-sonnet-4-5-20250929'
}
}
}
// 获取测试历史
const historyEndpoint = endpoint.replace('/test-config', '/test-history')
const historyRes = await fetch(historyEndpoint, {
headers: {
Authorization: authToken ? `Bearer ${authToken}` : ''
}
})
if (historyRes.ok) {
const historyData = await historyRes.json()
if (historyData.success && historyData.data?.history) {
testHistory.value = historyData.data.history
}
}
} catch (err) {
showToast('加载配置失败: ' + err.message, 'error')
} finally {
loading.value = false
}
}
// 保存配置
async function saveConfig() {
if (!props.account) return
saving.value = true
try {
const authToken = localStorage.getItem('authToken')
const platform = props.account.platform
let endpoint = ''
if (platform === 'claude') {
endpoint = `${API_PREFIX}/admin/claude-accounts/${props.account.id}/test-config`
} else {
saving.value = false
return
}
const res = await fetch(endpoint, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: authToken ? `Bearer ${authToken}` : ''
},
body: JSON.stringify({
enabled: config.value.enabled,
cronExpression: config.value.cronExpression,
model: config.value.model
})
})
if (res.ok) {
showToast('配置已保存', 'success')
emit('saved')
handleClose()
} else {
const errorData = await res.json().catch(() => ({}))
showToast(errorData.message || '保存失败', 'error')
}
} catch (err) {
showToast('保存失败: ' + err.message, 'error')
} finally {
saving.value = false
}
}
// 关闭模态框
function handleClose() {
if (saving.value) return
emit('close')
}
// 监听 show 变化,加载配置
watch(
() => props.show,
(newVal) => {
if (newVal) {
config.value = {
enabled: false,
cronExpression: '0 8 * * *',
model: 'claude-sonnet-4-5-20250929'
}
testHistory.value = []
loadConfig()
}
}
)
</script>

View File

@@ -1238,6 +1238,15 @@
<i class="fas fa-vial" />
<span class="ml-1">测试</span>
</button>
<button
v-if="canTestAccount(account)"
class="rounded bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-700 transition-colors hover:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-300 dark:hover:bg-amber-800/50"
title="定时测试配置"
@click="openScheduledTestModal(account)"
>
<i class="fas fa-clock" />
<span class="ml-1">定时</span>
</button>
<button
class="rounded bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200"
title="编辑账户"
@@ -1707,6 +1716,15 @@
测试
</button>
<button
v-if="canTestAccount(account)"
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-600 transition-colors hover:bg-amber-100 dark:bg-amber-900/40 dark:text-amber-300 dark:hover:bg-amber-800/50"
@click="openScheduledTestModal(account)"
>
<i class="fas fa-clock" />
定时
</button>
<button
class="flex-1 rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 transition-colors hover:bg-gray-100"
@click="editAccount(account)"
@@ -1880,6 +1898,14 @@
@close="closeAccountTestModal"
/>
<!-- 定时测试配置弹窗 -->
<AccountScheduledTestModal
:account="scheduledTestAccount"
:show="showScheduledTestModal"
@close="closeScheduledTestModal"
@saved="handleScheduledTestSaved"
/>
<!-- 账户统计弹窗 -->
<el-dialog
v-model="showAccountStatsModal"
@@ -2032,6 +2058,7 @@ import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue'
import AccountExpiryEditModal from '@/components/accounts/AccountExpiryEditModal.vue'
import AccountTestModal from '@/components/accounts/AccountTestModal.vue'
import AccountScheduledTestModal from '@/components/accounts/AccountScheduledTestModal.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
import CustomDropdown from '@/components/common/CustomDropdown.vue'
import ActionDropdown from '@/components/common/ActionDropdown.vue'
@@ -2099,6 +2126,10 @@ const expiryEditModalRef = ref(null)
const showAccountTestModal = ref(false)
const testingAccount = ref(null)
// 定时测试配置弹窗状态
const showScheduledTestModal = ref(false)
const scheduledTestAccount = ref(null)
// 账户统计弹窗状态
const showAccountStatsModal = ref(false)
@@ -2365,6 +2396,13 @@ const getAccountActions = (account) => {
color: 'blue',
handler: () => openAccountTestModal(account)
})
actions.push({
key: 'scheduled-test',
label: '定时测试',
icon: 'fa-clock',
color: 'amber',
handler: () => openScheduledTestModal(account)
})
}
// 删除
@@ -2441,6 +2479,25 @@ const closeAccountTestModal = () => {
testingAccount.value = null
}
// 定时测试配置相关函数
const openScheduledTestModal = (account) => {
if (!canTestAccount(account)) {
showToast('该账户类型暂不支持定时测试', 'warning')
return
}
scheduledTestAccount.value = account
showScheduledTestModal.value = true
}
const closeScheduledTestModal = () => {
showScheduledTestModal.value = false
scheduledTestAccount.value = null
}
const handleScheduledTestSaved = () => {
showToast('定时测试配置已保存', 'success')
}
// 计算排序后的账户列表
const sortedAccounts = computed(() => {
let sourceAccounts = accounts.value