mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Merge pull request #821 from guoyongchang/feat/cron-test-support
feat: Claude账户定时测试功能
This commit is contained in:
18
package-lock.json
generated
18
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
71
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
22
src/app.js
22
src/app.js
@@ -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...')
|
||||
|
||||
@@ -96,7 +96,25 @@ class RedisClient {
|
||||
logger.warn('⚠️ Redis connection closed')
|
||||
})
|
||||
|
||||
// 只有在 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
|
||||
|
||||
@@ -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
|
||||
|
||||
420
src/services/accountTestSchedulerService.js
Normal file
420
src/services/accountTestSchedulerService.js
Normal 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
|
||||
@@ -2456,19 +2456,14 @@ class ClaudeRelayService {
|
||||
}
|
||||
}
|
||||
|
||||
// 🧪 测试账号连接(供Admin API使用,直接复用 _makeClaudeStreamRequestWithUsageCapture)
|
||||
async testAccountConnection(accountId, responseStream) {
|
||||
const testRequestBody = createClaudeTestPayload('claude-sonnet-4-5-20250929', { stream: true })
|
||||
|
||||
try {
|
||||
// 🔧 准备测试请求的公共逻辑(供 testAccountConnection 和 testAccountConnectionSync 共用)
|
||||
async _prepareAccountForTest(accountId) {
|
||||
// 获取账户信息
|
||||
const account = await claudeAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
logger.info(`🧪 Testing Claude account connection: ${account.name} (${accountId})`)
|
||||
|
||||
// 获取有效的访问token
|
||||
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
|
||||
if (!accessToken) {
|
||||
@@ -2478,6 +2473,18 @@ class ClaudeRelayService {
|
||||
// 获取代理配置
|
||||
const proxyAgent = await this._getProxyAgent(accountId)
|
||||
|
||||
return { account, accessToken, proxyAgent }
|
||||
}
|
||||
|
||||
// 🧪 测试账号连接(供Admin API使用,直接复用 _makeClaudeStreamRequestWithUsageCapture)
|
||||
async testAccountConnection(accountId, responseStream, model = 'claude-sonnet-4-5-20250929') {
|
||||
const testRequestBody = createClaudeTestPayload(model, { stream: true })
|
||||
|
||||
try {
|
||||
const { account, accessToken, proxyAgent } = await this._prepareAccountForTest(accountId)
|
||||
|
||||
logger.info(`🧪 Testing Claude account connection: ${account.name} (${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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user