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",
|
"ioredis": "^5.3.2",
|
||||||
"ldapjs": "^3.0.7",
|
"ldapjs": "^3.0.7",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^7.0.6",
|
"nodemailer": "^7.0.6",
|
||||||
"ora": "^5.4.1",
|
"ora": "^5.4.1",
|
||||||
"rate-limiter-flexible": "^5.0.5",
|
"rate-limiter-flexible": "^5.0.5",
|
||||||
@@ -891,7 +892,6 @@
|
|||||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.3",
|
"@babel/generator": "^7.28.3",
|
||||||
@@ -3000,7 +3000,6 @@
|
|||||||
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
|
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -3082,7 +3081,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -3538,7 +3536,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001737",
|
"caniuse-lite": "^1.0.30001737",
|
||||||
"electron-to-chromium": "^1.5.211",
|
"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.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -4483,7 +4479,6 @@
|
|||||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"eslint-config-prettier": "bin/cli.js"
|
"eslint-config-prettier": "bin/cli.js"
|
||||||
},
|
},
|
||||||
@@ -7034,6 +7029,15 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/node-domexception": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||||
@@ -7582,7 +7586,6 @@
|
|||||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -9101,7 +9104,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz",
|
"resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz",
|
||||||
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
|
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@colors/colors": "^1.6.0",
|
"@colors/colors": "^1.6.0",
|
||||||
"@dabh/diagnostics": "^2.0.2",
|
"@dabh/diagnostics": "^2.0.2",
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"ldapjs": "^3.0.7",
|
"ldapjs": "^3.0.7",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^7.0.6",
|
"nodemailer": "^7.0.6",
|
||||||
"ora": "^5.4.1",
|
"ora": "^5.4.1",
|
||||||
"rate-limiter-flexible": "^5.0.5",
|
"rate-limiter-flexible": "^5.0.5",
|
||||||
|
|||||||
71
pnpm-lock.yaml
generated
71
pnpm-lock.yaml
generated
@@ -59,6 +59,9 @@ importers:
|
|||||||
morgan:
|
morgan:
|
||||||
specifier: ^1.10.0
|
specifier: ^1.10.0
|
||||||
version: 1.10.1
|
version: 1.10.1
|
||||||
|
node-cron:
|
||||||
|
specifier: ^4.2.1
|
||||||
|
version: 4.2.1
|
||||||
nodemailer:
|
nodemailer:
|
||||||
specifier: ^7.0.6
|
specifier: ^7.0.6
|
||||||
version: 7.0.11
|
version: 7.0.11
|
||||||
@@ -108,6 +111,9 @@ importers:
|
|||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.6.2
|
specifier: ^3.6.2
|
||||||
version: 3.7.4
|
version: 3.7.4
|
||||||
|
prettier-plugin-tailwindcss:
|
||||||
|
specifier: ^0.7.2
|
||||||
|
version: 0.7.2(prettier@3.7.4)
|
||||||
supertest:
|
supertest:
|
||||||
specifier: ^6.3.3
|
specifier: ^6.3.3
|
||||||
version: 6.3.4
|
version: 6.3.4
|
||||||
@@ -2144,6 +2150,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
|
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
|
||||||
engines: {node: '>= 0.6'}
|
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:
|
node-domexception@1.0.0:
|
||||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||||
engines: {node: '>=10.5.0'}
|
engines: {node: '>=10.5.0'}
|
||||||
@@ -2302,6 +2312,61 @@ packages:
|
|||||||
resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==}
|
resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==}
|
||||||
engines: {node: '>=6.0.0'}
|
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:
|
prettier@3.7.4:
|
||||||
resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==}
|
resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -5692,6 +5757,8 @@ snapshots:
|
|||||||
|
|
||||||
negotiator@0.6.4: {}
|
negotiator@0.6.4: {}
|
||||||
|
|
||||||
|
node-cron@4.2.1: {}
|
||||||
|
|
||||||
node-domexception@1.0.0: {}
|
node-domexception@1.0.0: {}
|
||||||
|
|
||||||
node-fetch@3.3.2:
|
node-fetch@3.3.2:
|
||||||
@@ -5840,6 +5907,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
fast-diff: 1.3.0
|
fast-diff: 1.3.0
|
||||||
|
|
||||||
|
prettier-plugin-tailwindcss@0.7.2(prettier@3.7.4):
|
||||||
|
dependencies:
|
||||||
|
prettier: 3.7.4
|
||||||
|
|
||||||
prettier@3.7.4: {}
|
prettier@3.7.4: {}
|
||||||
|
|
||||||
pretty-format@29.7.0:
|
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)'
|
'🚦 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() {
|
setupGracefulShutdown() {
|
||||||
@@ -715,6 +728,15 @@ class Application {
|
|||||||
logger.error('❌ Error stopping cost rank service:', error)
|
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 修复:防止重启泄漏)
|
// 🔢 清理所有并发计数(Phase 1 修复:防止重启泄漏)
|
||||||
try {
|
try {
|
||||||
logger.info('🔢 Cleaning up all concurrency counters...')
|
logger.info('🔢 Cleaning up all concurrency counters...')
|
||||||
|
|||||||
@@ -96,7 +96,25 @@ class RedisClient {
|
|||||||
logger.warn('⚠️ Redis connection closed')
|
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
|
return this.client
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('💥 Failed to connect to Redis:', 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
|
module.exports = redisClient
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const router = express.Router()
|
|||||||
const claudeAccountService = require('../../services/claudeAccountService')
|
const claudeAccountService = require('../../services/claudeAccountService')
|
||||||
const claudeRelayService = require('../../services/claudeRelayService')
|
const claudeRelayService = require('../../services/claudeRelayService')
|
||||||
const accountGroupService = require('../../services/accountGroupService')
|
const accountGroupService = require('../../services/accountGroupService')
|
||||||
|
const accountTestSchedulerService = require('../../services/accountTestSchedulerService')
|
||||||
const apiKeyService = require('../../services/apiKeyService')
|
const apiKeyService = require('../../services/apiKeyService')
|
||||||
const redis = require('../../models/redis')
|
const redis = require('../../models/redis')
|
||||||
const { authenticateAdmin } = require('../../middleware/auth')
|
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', {
|
logger.info('🍪 Starting Cookie-based OAuth authorization', {
|
||||||
sessionKeyLength: trimmedSessionKey.length,
|
sessionKeyLength: trimmedSessionKey.length,
|
||||||
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
|
sessionKeyPrefix: `${trimmedSessionKey.substring(0, 10)}...`,
|
||||||
hasProxy: !!proxy
|
hasProxy: !!proxy
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -326,7 +327,7 @@ router.post('/claude-accounts/setup-token-with-cookie', authenticateAdmin, async
|
|||||||
|
|
||||||
logger.info('🍪 Starting Cookie-based Setup Token authorization', {
|
logger.info('🍪 Starting Cookie-based Setup Token authorization', {
|
||||||
sessionKeyLength: trimmedSessionKey.length,
|
sessionKeyLength: trimmedSessionKey.length,
|
||||||
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
|
sessionKeyPrefix: `${trimmedSessionKey.substring(0, 10)}...`,
|
||||||
hasProxy: !!proxy
|
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
|
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,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)
|
// 🧪 测试账号连接(供Admin API使用,直接复用 _makeClaudeStreamRequestWithUsageCapture)
|
||||||
async testAccountConnection(accountId, responseStream) {
|
async testAccountConnection(accountId, responseStream, model = 'claude-sonnet-4-5-20250929') {
|
||||||
const testRequestBody = createClaudeTestPayload('claude-sonnet-4-5-20250929', { stream: true })
|
const testRequestBody = createClaudeTestPayload(model, { stream: true })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取账户信息
|
const { account, accessToken, proxyAgent } = await this._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})`)
|
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) {
|
if (!responseStream.headersSent) {
|
||||||
const existingConnection = responseStream.getHeader
|
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() {
|
async healthCheck() {
|
||||||
try {
|
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" />
|
<i class="fas fa-vial" />
|
||||||
<span class="ml-1">测试</span>
|
<span class="ml-1">测试</span>
|
||||||
</button>
|
</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
|
<button
|
||||||
class="rounded bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200"
|
class="rounded bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200"
|
||||||
title="编辑账户"
|
title="编辑账户"
|
||||||
@@ -1707,6 +1716,15 @@
|
|||||||
测试
|
测试
|
||||||
</button>
|
</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
|
<button
|
||||||
class="flex-1 rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 transition-colors hover:bg-gray-100"
|
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)"
|
@click="editAccount(account)"
|
||||||
@@ -1880,6 +1898,14 @@
|
|||||||
@close="closeAccountTestModal"
|
@close="closeAccountTestModal"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 定时测试配置弹窗 -->
|
||||||
|
<AccountScheduledTestModal
|
||||||
|
:account="scheduledTestAccount"
|
||||||
|
:show="showScheduledTestModal"
|
||||||
|
@close="closeScheduledTestModal"
|
||||||
|
@saved="handleScheduledTestSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 账户统计弹窗 -->
|
<!-- 账户统计弹窗 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="showAccountStatsModal"
|
v-model="showAccountStatsModal"
|
||||||
@@ -2032,6 +2058,7 @@ import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
|
|||||||
import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue'
|
import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue'
|
||||||
import AccountExpiryEditModal from '@/components/accounts/AccountExpiryEditModal.vue'
|
import AccountExpiryEditModal from '@/components/accounts/AccountExpiryEditModal.vue'
|
||||||
import AccountTestModal from '@/components/accounts/AccountTestModal.vue'
|
import AccountTestModal from '@/components/accounts/AccountTestModal.vue'
|
||||||
|
import AccountScheduledTestModal from '@/components/accounts/AccountScheduledTestModal.vue'
|
||||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||||
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
||||||
import ActionDropdown from '@/components/common/ActionDropdown.vue'
|
import ActionDropdown from '@/components/common/ActionDropdown.vue'
|
||||||
@@ -2099,6 +2126,10 @@ const expiryEditModalRef = ref(null)
|
|||||||
const showAccountTestModal = ref(false)
|
const showAccountTestModal = ref(false)
|
||||||
const testingAccount = ref(null)
|
const testingAccount = ref(null)
|
||||||
|
|
||||||
|
// 定时测试配置弹窗状态
|
||||||
|
const showScheduledTestModal = ref(false)
|
||||||
|
const scheduledTestAccount = ref(null)
|
||||||
|
|
||||||
// 账户统计弹窗状态
|
// 账户统计弹窗状态
|
||||||
const showAccountStatsModal = ref(false)
|
const showAccountStatsModal = ref(false)
|
||||||
|
|
||||||
@@ -2365,6 +2396,13 @@ const getAccountActions = (account) => {
|
|||||||
color: 'blue',
|
color: 'blue',
|
||||||
handler: () => openAccountTestModal(account)
|
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
|
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(() => {
|
const sortedAccounts = computed(() => {
|
||||||
let sourceAccounts = accounts.value
|
let sourceAccounts = accounts.value
|
||||||
|
|||||||
Reference in New Issue
Block a user