refactor: optimize cron test support feature

**优化内容:**

1. **验证和安全性加强**
   - 移除cron验证重复,统一使用accountTestSchedulerService.validateCronExpression()方法
   - 添加model参数类型和长度验证(max 256 chars)
   - 限制cronExpression长度至100字符防止DoS攻击
   - 双层验证:service层和route层都进行长度检查

2. **性能优化**
   - 优化_refreshAllTasks()使用Promise.all()并行加载所有平台配置(之前是顺序加载)
   - 改进错误处理,平台加载失败时继续处理其他平台

3. **数据管理改进**
   - 为test config添加1年TTL过期机制(之前没有过期设置)
   - 保证test history已有30天TTL和5条记录限制

4. **错误响应标准化**
   - 统一所有API响应格式,确保error状态都包含message字段
   - 改进错误消息的可读性和上下文信息

5. **用户体验改进**
   - Vue组件使用showToast()替代原生alert()
   - 移除console.error()改用toast通知用户
   - 成功保存时显示成功提示

6. **代码整理**
   - 移除未使用的maxConcurrentTests变量及其getStatus()中的引用
   - 保持代码整洁性

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
guoyongchang
2025-12-19 13:39:39 +08:00
parent 9977245d59
commit cd3f51e9e2
4 changed files with 80 additions and 46 deletions

View File

@@ -3181,6 +3181,7 @@ redisClient.scanConcurrencyQueueStatsKeys = async function () {
const ACCOUNT_TEST_HISTORY_MAX = 5 // 保留最近5次测试记录 const ACCOUNT_TEST_HISTORY_MAX = 5 // 保留最近5次测试记录
const ACCOUNT_TEST_HISTORY_TTL = 86400 * 30 // 30天过期 const ACCOUNT_TEST_HISTORY_TTL = 86400 * 30 // 30天过期
const ACCOUNT_TEST_CONFIG_TTL = 86400 * 365 // 测试配置保留1年用户通常长期使用
/** /**
* 保存账户测试结果 * 保存账户测试结果
@@ -3300,6 +3301,8 @@ redisClient.saveAccountTestConfig = async function (accountId, platform, testCon
model: testConfig.model || 'claude-sonnet-4-5-20250929', // 默认模型 model: testConfig.model || 'claude-sonnet-4-5-20250929', // 默认模型
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
}) })
// 设置过期时间1年
await client.expire(key, ACCOUNT_TEST_CONFIG_TTL)
} catch (error) { } catch (error) {
logger.error(`Failed to save test config for ${accountId}:`, error) logger.error(`Failed to save test config for ${accountId}:`, error)
} }

View File

@@ -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')
@@ -959,7 +960,7 @@ router.put('/claude-accounts/:accountId/test-config', authenticateAdmin, async (
const { enabled, cronExpression, model } = req.body const { enabled, cronExpression, model } = req.body
try { try {
// 验证参数 // 验证 enabled 参数
if (typeof enabled !== 'boolean') { if (typeof enabled !== 'boolean') {
return res.status(400).json({ return res.status(400).json({
error: 'Invalid parameter', error: 'Invalid parameter',
@@ -967,7 +968,7 @@ router.put('/claude-accounts/:accountId/test-config', authenticateAdmin, async (
}) })
} }
// 验证 cron 表达式 // 验证 cronExpression 参数
if (!cronExpression || typeof cronExpression !== 'string') { if (!cronExpression || typeof cronExpression !== 'string') {
return res.status(400).json({ return res.status(400).json({
error: 'Invalid parameter', error: 'Invalid parameter',
@@ -975,23 +976,38 @@ router.put('/claude-accounts/:accountId/test-config', authenticateAdmin, async (
}) })
} }
// 使用 node-cron 验证表达式 // 限制 cronExpression 长度防止 DoS
const cron = require('node-cron') const MAX_CRON_LENGTH = 100
if (!cron.validate(cronExpression)) { 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({ return res.status(400).json({
error: 'Invalid parameter', error: 'Invalid parameter',
message: `Invalid cron expression: ${cronExpression}. Format: "minute hour day month weekday" (e.g., "0 8 * * *" for daily at 8:00)` 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' 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) const account = await claudeAccountService.getAccount(accountId)
if (!account) { if (!account) {
return res.status(404).json({ return res.status(404).json({
error: 'Account not found' error: 'Account not found',
message: `Claude account ${accountId} not found`
}) })
} }
@@ -1033,7 +1049,8 @@ router.post('/claude-accounts/:accountId/test-sync', authenticateAdmin, async (r
const account = await claudeAccountService.getAccount(accountId) const account = await claudeAccountService.getAccount(accountId)
if (!account) { if (!account) {
return res.status(404).json({ return res.status(404).json({
error: 'Account not found' error: 'Account not found',
message: `Claude account ${accountId} not found`
}) })
} }

View File

@@ -14,8 +14,6 @@ class AccountTestSchedulerService {
// 定期刷新配置的间隔 (毫秒) // 定期刷新配置的间隔 (毫秒)
this.refreshIntervalMs = 60 * 1000 this.refreshIntervalMs = 60 * 1000
this.refreshInterval = null this.refreshInterval = null
// 测试并发限制
this.maxConcurrentTests = 3
// 当前正在测试的账户 // 当前正在测试的账户
this.testingAccounts = new Set() this.testingAccounts = new Set()
// 是否已启动 // 是否已启动
@@ -28,6 +26,10 @@ class AccountTestSchedulerService {
* @returns {boolean} * @returns {boolean}
*/ */
validateCronExpression(cronExpression) { validateCronExpression(cronExpression) {
// 长度检查(防止 DoS
if (!cronExpression || cronExpression.length > 100) {
return false
}
return cron.validate(cronExpression) return cron.validate(cronExpression)
} }
@@ -85,10 +87,23 @@ class AccountTestSchedulerService {
const platforms = ['claude', 'gemini', 'openai'] const platforms = ['claude', 'gemini', 'openai']
const activeAccountKeys = new Set() const activeAccountKeys = new Set()
for (const platform of platforms) { // 并行加载所有平台的配置
const enabledAccounts = await redis.getEnabledTestAccounts(platform) 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 []
})
)
)
for (const { accountId, cronExpression, model } of enabledAccounts) { // 展平平台数据
const flatAccounts = allEnabledAccounts.flat()
for (const { accountId, cronExpression, model, platform } of flatAccounts) {
if (!cronExpression) { if (!cronExpression) {
logger.warn( logger.warn(
`⚠️ Account ${accountId} (${platform}) has no valid cron expression, skipping` `⚠️ Account ${accountId} (${platform}) has no valid cron expression, skipping`
@@ -120,7 +135,6 @@ class AccountTestSchedulerService {
// 创建新的 cron 任务 // 创建新的 cron 任务
this._createCronTask(accountId, platform, cronExpression, model) this._createCronTask(accountId, platform, cronExpression, model)
} }
}
// 清理已删除或禁用的账户任务 // 清理已删除或禁用的账户任务
for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) { for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) {
@@ -397,7 +411,6 @@ class AccountTestSchedulerService {
return { return {
running: this.isStarted, running: this.isStarted,
refreshIntervalMs: this.refreshIntervalMs, refreshIntervalMs: this.refreshIntervalMs,
maxConcurrentTests: this.maxConcurrentTests,
scheduledTasksCount: this.scheduledTasks.size, scheduledTasksCount: this.scheduledTasks.size,
scheduledTasks: tasks, scheduledTasks: tasks,
currentlyTesting: Array.from(this.testingAccounts) currentlyTesting: Array.from(this.testingAccounts)

View File

@@ -221,6 +221,7 @@
<script setup> <script setup>
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { API_PREFIX } from '@/config/api' import { API_PREFIX } from '@/config/api'
import { showToast } from '@/utils/toast'
const props = defineProps({ const props = defineProps({
show: { show: {
@@ -326,7 +327,7 @@ async function loadConfig() {
} }
} }
} catch (err) { } catch (err) {
console.error('Failed to load test config:', err) showToast('加载配置失败: ' + err.message, 'error')
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -363,15 +364,15 @@ async function saveConfig() {
}) })
if (res.ok) { if (res.ok) {
showToast('配置已保存', 'success')
emit('saved') emit('saved')
handleClose() handleClose()
} else { } else {
const errorData = await res.json().catch(() => ({})) const errorData = await res.json().catch(() => ({}))
alert(errorData.message || '保存失败') showToast(errorData.message || '保存失败', 'error')
} }
} catch (err) { } catch (err) {
console.error('Failed to save test config:', err) showToast('保存失败: ' + err.message, 'error')
alert('保存失败: ' + err.message)
} finally { } finally {
saving.value = false saving.value = false
} }