From a97b05dd2d4902837a6e34b6dca7d4015e655a29 Mon Sep 17 00:00:00 2001 From: Wenaixi Date: Thu, 2 Apr 2026 00:55:49 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E5=8F=91=E7=8E=B0=E7=9A=8410=E4=B8=AA?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E4=B8=8E=E6=AD=A3=E7=A1=AE=E6=80=A7=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix: provider-pool-manager: 移除 if(true) 占位符,改为读取凭据文件真实过期时间 - fix: provider-pool-manager: Math.min 展开大数组改为 reduce,防止栈溢出 - fix: provider-pool-manager: forceRefreshToken 调用前检查方法是否实现,不存在则 fallback - fix: provider-api: handleAddProvider 默认路径统一为 configs/provider_pools.json - fix: config-api: handleGetConfig 改为白名单字段过滤,REQUIRED_API_KEY 脱敏返回 - fix: api-server: 启动日志中 API Key 遮码处理 - fix: utils: generateUUID 改用 crypto.randomUUID() 替代 Math.random() - fix: config-manager: renderProviderTags innerHTML 加 escHtml 防 XSS 注入 - fix: config-manager: PROVIDER_POOLS_FILE_PATH 未定义时加 || '' 兜底 - fix: section-config.css: white 改为 var(--bg-primary, white) 支持暗黑模式 - chore: .gitignore 添加 AGENTS.md - chore: docker-compose.yml 添加代理环境变量 --- .gitignore | 1 + docker/docker-compose.yml | 6 +++ src/providers/provider-pool-manager.js | 28 ++++++++++---- src/services/api-server.js | 14 +++---- src/ui-modules/config-api.js | 51 +++++++++++++++++++++++--- src/ui-modules/provider-api.js | 2 +- src/utils/provider-utils.js | 6 +-- static/app/config-manager.js | 11 +++--- static/components/section-config.css | 2 +- 9 files changed, 89 insertions(+), 32 deletions(-) diff --git a/.gitignore b/.gitignore index f752bb4..c375cbc 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,5 @@ api-potluck-keys.json api-potluck-data.json # Codex credentials configs/codex/ +AGENTS.md diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 6977d13..8c08ef3 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -14,6 +14,12 @@ services: - ./configs:/app/configs environment: - ARGS= + - HTTP_PROXY=http://host.docker.internal:10801 + - http_proxy=http://host.docker.internal:10801 + - HTTPS_PROXY=http://host.docker.internal:10801 + - https_proxy=http://host.docker.internal:10801 + - NO_PROXY=localhost,127.0.0.1,host.docker.internal + - no_proxy=localhost,127.0.0.1,host.docker.internal healthcheck: test: ["CMD", "node", "healthcheck.js"] interval: 30s diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index f039e48..5875f1f 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -112,7 +112,12 @@ export class ProviderPoolManager { if (configPath && fs.existsSync(configPath)) { try { - if (true) { + const fileContent = fs.readFileSync(configPath, 'utf-8'); + const credData = JSON.parse(fileContent); + const expiryTime = credData.expiry_date || credData.expiry || credData.expires_at; + const nearExpiryMs = (currentConfig?.CRON_NEAR_MINUTES || 10) * 60 * 1000; + const isNearExpiry = expiryTime && (expiryTime - Date.now()) < nearExpiryMs; + if (isNearExpiry) { this._log('warn', `Node ${providerStatus.uuid} (${providerType}) is near expiration. Enqueuing refresh...`); this._enqueueRefresh(providerType, providerStatus); } @@ -389,7 +394,16 @@ export class ProviderPoolManager { // 调用适配器的 refreshToken 方法(内部封装了具体的刷新逻辑) if (typeof serviceAdapter.refreshToken === 'function') { const startTime = Date.now(); - force ? await serviceAdapter.forceRefreshToken() : await serviceAdapter.refreshToken() + if (force) { + if (typeof serviceAdapter.forceRefreshToken === 'function') { + await serviceAdapter.forceRefreshToken(); + } else { + this._log('warn', `forceRefreshToken not implemented for ${providerType}, falling back to refreshToken`); + await serviceAdapter.refreshToken(); + } + } else { + await serviceAdapter.refreshToken(); + } const duration = Date.now() - startTime; this._log('info', `Token refresh successful for node ${providerStatus.uuid} (Duration: ${duration}ms)`); @@ -452,7 +466,7 @@ export class ProviderPoolManager { const lastSelectionSeq = config._lastSelectionSeq || 0; if (minSeqInPool === -1) { const pool = this.providerStatus[providerStatus.type] || []; - minSeqInPool = Math.min(...pool.map(p => p.config._lastSelectionSeq || 0)); + minSeqInPool = pool.reduce((min, p) => Math.min(min, p.config._lastSelectionSeq || 0), Infinity); } const relativeSeq = Math.max(0, lastSelectionSeq - minSeqInPool); const cappedRelativeSeq = Math.min(relativeSeq, 100); @@ -1819,14 +1833,14 @@ export class ProviderPoolManager { continue; } - const checkStartTime = Date.now(); + const providerCheckStart = Date.now(); const checkModelName = provider.config.checkModelName || ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType] || 'unknown'; const displayName = customName || uuid.substring(0, 8); - + try { // Perform health check (health check is based on providerTypes configuration, not per-provider checkHealth flag) const result = await this._checkProviderHealth(providerType, provider.config); - const checkDuration = Date.now() - checkStartTime; + const checkDuration = Date.now() - providerCheckStart; if (!result.success) { // Provider is unhealthy @@ -1840,7 +1854,7 @@ export class ProviderPoolManager { this.markProviderHealthy(providerType, provider.config, false, result.modelName); } } catch (error) { - const checkDuration = Date.now() - checkStartTime; + const checkDuration = Date.now() - providerCheckStart; failCount++; this._log('error', `[ScheduledHealthCheck] ${displayName} (${providerType}) EXCEPTION: ${error.message} (${checkDuration}ms)`); this.markProviderUnhealthyImmediately(providerType, provider.config, error.message); diff --git a/src/services/api-server.js b/src/services/api-server.js index ad9bdc9..c1a850f 100644 --- a/src/services/api-server.js +++ b/src/services/api-server.js @@ -313,7 +313,7 @@ async function startServer() { logger.info(` System Prompt Mode: ${CONFIG.SYSTEM_PROMPT_MODE}`); logger.info(` Host: ${CONFIG.HOST}`); logger.info(` Port: ${CONFIG.SERVER_PORT}`); - logger.info(` Required API Key: ${CONFIG.REQUIRED_API_KEY}`); + logger.info(` Required API Key: ${CONFIG.REQUIRED_API_KEY ? CONFIG.REQUIRED_API_KEY.slice(0, 4) + '****' : '(none)'}`); logger.info(` Prompt Logging: ${CONFIG.PROMPT_LOG_MODE}${CONFIG.PROMPT_LOG_FILENAME ? ` (to ${CONFIG.PROMPT_LOG_FILENAME})` : ''}`); logger.info(`------------------------------------------`); logger.info(`\nUnified API Server running on http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}`); @@ -355,14 +355,14 @@ async function startServer() { setInterval(heartbeatAndRefreshToken, CONFIG.CRON_NEAR_MINUTES * 60 * 1000); } // 服务器完全启动后,执行初始健康检查 - const poolManager = getProviderPoolManager(); - if (poolManager) { - logger.info('[Initialization] Performing initial health checks for provider pools...'); - poolManager.performHealthChecks(); - } + const poolManager = getProviderPoolManager(); + if (poolManager) { + logger.info('[Initialization] Performing initial health checks for provider pools...'); + poolManager.performHealthChecks(); + } // 定时健康检查 - const scheduledConfig = CONFIG.SCHEDULED_HEALTH_CHECK; + const scheduledConfig = CONFIG.SCHEDULED_HEALTH_CHECK; if (scheduledConfig?.enabled) { // 设计决策:只验证最小值 60000ms,不设最大值。 // 前端有 max=3600000 (1小时) 的 UI 限制,但后端允许更大值以支持特殊需求。 diff --git a/src/ui-modules/config-api.js b/src/ui-modules/config-api.js index 92c4dac..f6f2b0e 100644 --- a/src/ui-modules/config-api.js +++ b/src/ui-modules/config-api.js @@ -57,11 +57,48 @@ export async function handleGetConfig(req, res, currentConfig) { } } + // 白名单过滤:只返回前端需要的字段,避免泄露凭据路径、内部状态等敏感信息 + const safeConfig = { + HOST: currentConfig.HOST, + SERVER_PORT: currentConfig.SERVER_PORT, + MODEL_PROVIDER: currentConfig.MODEL_PROVIDER, + SYSTEM_PROMPT_FILE_PATH: currentConfig.SYSTEM_PROMPT_FILE_PATH, + SYSTEM_PROMPT_MODE: currentConfig.SYSTEM_PROMPT_MODE, + PROMPT_LOG_BASE_NAME: currentConfig.PROMPT_LOG_BASE_NAME, + PROMPT_LOG_MODE: currentConfig.PROMPT_LOG_MODE, + REQUEST_MAX_RETRIES: currentConfig.REQUEST_MAX_RETRIES, + REQUEST_BASE_DELAY: currentConfig.REQUEST_BASE_DELAY, + CREDENTIAL_SWITCH_MAX_RETRIES: currentConfig.CREDENTIAL_SWITCH_MAX_RETRIES, + CRON_NEAR_MINUTES: currentConfig.CRON_NEAR_MINUTES, + CRON_REFRESH_TOKEN: currentConfig.CRON_REFRESH_TOKEN, + LOGIN_EXPIRY: currentConfig.LOGIN_EXPIRY, + PROVIDER_POOLS_FILE_PATH: currentConfig.PROVIDER_POOLS_FILE_PATH, + MAX_ERROR_COUNT: currentConfig.MAX_ERROR_COUNT, + WARMUP_TARGET: currentConfig.WARMUP_TARGET, + REFRESH_CONCURRENCY_PER_PROVIDER: currentConfig.REFRESH_CONCURRENCY_PER_PROVIDER, + providerFallbackChain: currentConfig.providerFallbackChain, + modelFallbackMapping: currentConfig.modelFallbackMapping, + PROXY_URL: currentConfig.PROXY_URL, + PROXY_ENABLED_PROVIDERS: currentConfig.PROXY_ENABLED_PROVIDERS, + TLS_SIDECAR_ENABLED: currentConfig.TLS_SIDECAR_ENABLED, + TLS_SIDECAR_ENABLED_PROVIDERS: currentConfig.TLS_SIDECAR_ENABLED_PROVIDERS, + TLS_SIDECAR_PORT: currentConfig.TLS_SIDECAR_PORT, + TLS_SIDECAR_PROXY_URL: currentConfig.TLS_SIDECAR_PROXY_URL, + LOG_ENABLED: currentConfig.LOG_ENABLED, + LOG_OUTPUT_MODE: currentConfig.LOG_OUTPUT_MODE, + LOG_LEVEL: currentConfig.LOG_LEVEL, + LOG_DIR: currentConfig.LOG_DIR, + LOG_INCLUDE_REQUEST_ID: currentConfig.LOG_INCLUDE_REQUEST_ID, + LOG_INCLUDE_TIMESTAMP: currentConfig.LOG_INCLUDE_TIMESTAMP, + LOG_MAX_FILE_SIZE: currentConfig.LOG_MAX_FILE_SIZE, + LOG_MAX_FILES: currentConfig.LOG_MAX_FILES, + SCHEDULED_HEALTH_CHECK: currentConfig.SCHEDULED_HEALTH_CHECK, + // 脱敏:只返回是否设置了 API Key,不返回原文 + REQUIRED_API_KEY: currentConfig.REQUIRED_API_KEY ? '******' : '', + systemPrompt, + }; res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - ...currentConfig, - systemPrompt - })); + res.end(JSON.stringify(safeConfig)); return true; } @@ -129,8 +166,10 @@ export async function handleUpdateConfig(req, res, currentConfig) { providerTypes: Array.isArray(incoming?.providerTypes) ? incoming.providerTypes : [] }; - // 如果定时器已存在且 enabled,重新加载 timer(interval 变化时) - if (globalThis.reloadHealthCheckTimer && currentConfig.SCHEDULED_HEALTH_CHECK.enabled) { + // 如果定时器已存在且 enabled,仅在 interval 实际变化时重新加载 timer + const previousInterval = currentConfig.SCHEDULED_HEALTH_CHECK._activeInterval; + if (globalThis.reloadHealthCheckTimer && currentConfig.SCHEDULED_HEALTH_CHECK.enabled && newInterval !== previousInterval) { + currentConfig.SCHEDULED_HEALTH_CHECK._activeInterval = newInterval; globalThis.reloadHealthCheckTimer(newInterval); } } diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index 2789de3..a2a02fc 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -115,7 +115,7 @@ export async function handleAddProvider(req, res, currentConfig, providerPoolMan providerConfig.errorCount = providerConfig.errorCount || 0; providerConfig.lastErrorTime = providerConfig.lastErrorTime || null; - const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json'; + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; let providerPools = {}; // Load existing pools diff --git a/src/utils/provider-utils.js b/src/utils/provider-utils.js index 905b8e8..d7e9134 100644 --- a/src/utils/provider-utils.js +++ b/src/utils/provider-utils.js @@ -97,11 +97,7 @@ export const PROVIDER_MAPPINGS = [ * @returns {string} UUID 字符串 */ export function generateUUID() { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - const r = Math.random() * 16 | 0; - const v = c === 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); + return crypto.randomUUID(); } /** diff --git a/static/app/config-manager.js b/static/app/config-manager.js index 130bfdc..36ac1cb 100644 --- a/static/app/config-manager.js +++ b/static/app/config-manager.js @@ -53,10 +53,11 @@ function renderProviderTags(container, configs, isRequired) { // 过滤掉不可见的提供商 const visibleConfigs = configs.filter(c => c.visible !== false); + const escHtml = s => String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); container.innerHTML = visibleConfigs.map(c => ` - `).join(''); @@ -157,7 +158,7 @@ async function loadConfiguration() { if (cronNearMinutesEl) cronNearMinutesEl.value = data.CRON_NEAR_MINUTES || 1; if (cronRefreshTokenEl) cronRefreshTokenEl.checked = data.CRON_REFRESH_TOKEN || false; if (loginExpiryEl) loginExpiryEl.value = data.LOGIN_EXPIRY || 3600; - if (providerPoolsFilePathEl) providerPoolsFilePathEl.value = data.PROVIDER_POOLS_FILE_PATH; + if (providerPoolsFilePathEl) providerPoolsFilePathEl.value = data.PROVIDER_POOLS_FILE_PATH || ''; if (maxErrorCountEl) maxErrorCountEl.value = data.MAX_ERROR_COUNT || 10; if (warmupTargetEl) warmupTargetEl.value = data.WARMUP_TARGET || 0; if (refreshConcurrencyPerProviderEl) refreshConcurrencyPerProviderEl.value = data.REFRESH_CONCURRENCY_PER_PROVIDER || 1; @@ -248,7 +249,7 @@ async function loadConfiguration() { const scheduledHealthCheckIntervalEl = document.getElementById('scheduledHealthCheckInterval'); if (data.SCHEDULED_HEALTH_CHECK) { - if (scheduledHealthCheckEnabledEl) scheduledHealthCheckEnabledEl.checked = data.SCHEDULED_HEALTH_CHECK.enabled !== false; + if (scheduledHealthCheckEnabledEl) scheduledHealthCheckEnabledEl.checked = data.SCHEDULED_HEALTH_CHECK.enabled === true; if (scheduledHealthCheckStartupRunEl) scheduledHealthCheckStartupRunEl.checked = data.SCHEDULED_HEALTH_CHECK.startupRun !== false; if (scheduledHealthCheckIntervalEl) scheduledHealthCheckIntervalEl.value = data.SCHEDULED_HEALTH_CHECK.interval || 600000; } else { diff --git a/static/components/section-config.css b/static/components/section-config.css index e16338b..aebf6c4 100644 --- a/static/components/section-config.css +++ b/static/components/section-config.css @@ -173,7 +173,7 @@ textarea.form-control { width: 18px; left: 3px; bottom: 2px; - background-color: white; + background-color: var(--bg-primary, white); transition: var(--transition); border-radius: 50%; box-shadow: 0 1px 3px var(--neutral-shadow-30);