diff --git a/README-JA.md b/README-JA.md index 7670d7c..9c806ba 100644 --- a/README-JA.md +++ b/README-JA.md @@ -138,6 +138,13 @@ docker compose up -d * **Linux/macOS**: `chmod +x install-and-run.sh && ./install-and-run.sh` * **Windows**: `install-and-run.bat` をダブルクリックして実行 +> **💡 スクリプトの実行に失敗した場合は、手動で依存関係をインストールして起動できます:** +> ```bash +> npm install +> npm start +> ``` + + #### 2. コンソールへのアクセス サーバー起動後、ブラウザで以下にアクセスしてください: 👉 [**http://localhost:3000**](http://localhost:3000) diff --git a/README-ZH.md b/README-ZH.md index 68ca6ff..2e09ecd 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -137,6 +137,13 @@ docker compose up -d * **Linux/macOS**: `chmod +x install-and-run.sh && ./install-and-run.sh` * **Windows**: 双击运行 `install-and-run.bat` +> **💡 如果脚本运行失败,可以尝试手动安装依赖并启动:** +> ```bash +> npm install +> npm start +> ``` + + #### 2. 访问控制台 服务器启动后,打开浏览器访问: 👉 [**http://localhost:3000**](http://localhost:3000) diff --git a/README.md b/README.md index bc90009..1a63451 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,13 @@ To build from source instead of using the pre-built image, edit `docker-compose. * **Linux/macOS**: `chmod +x install-and-run.sh && ./install-and-run.sh` * **Windows**: Double-click `install-and-run.bat` +> **💡 If the script fails, you can try manually installing dependencies and starting:** +> ```bash +> npm install +> npm start +> ``` + + #### 2. Access the console After the server starts, open your browser and visit: 👉 [**http://localhost:3000**](http://localhost:3000) diff --git a/install-and-run.bat b/install-and-run.bat index 1306bce..73a240e 100755 --- a/install-and-run.bat +++ b/install-and-run.bat @@ -1,101 +1,16 @@ @echo off -rem Ensure the script uses Windows CRLF line endings and UTF-8 encoding -chcp 65001 >nul -setlocal enabledelayedexpansion +setlocal +cd /d "%~dp0" -:: 处理参数 -set FORCE_PULL=0 - -for %%a in (%*) do ( - if "%%a"=="--pull" set FORCE_PULL=1 -) - -echo ======================================== -echo AI Client 2 API 快速安装启动脚本 -echo ======================================== -echo. - -:: 检查Git并尝试pull -if !FORCE_PULL! equ 1 ( - echo [更新] 正在从远程仓库拉取最新代码... - git --version >nul 2>&1 - if %errorlevel% equ 0 ( - git pull - if %errorlevel% neq 0 ( - echo [警告] Git pull 失败,请检查网络或手动处理冲突。 - ) else ( - echo [成功] 代码已更新。 - ) - ) else ( - echo [警告] 未检测到 Git,跳过代码拉取。 - ) -) - -:: 检查Node.js是否已安装 -echo [检查] 正在检查Node.js是否已安装... -node --version >nul 2>&1 +:: Check for powershell +where powershell >nul 2>&1 if %errorlevel% neq 0 ( - echo [错误] 未检测到Node.js,请先安装Node.js - echo 下载地址:https://nodejs.org/ - echo 提示:推荐安装LTS版本 + echo [ERROR] PowerShell not found. Please run 'node src/core/master.js' manually. pause exit /b 1 ) -:: 获取Node.js版本 -for /f "tokens=*" %%i in ('node --version') do set NODE_VERSION=%%i -echo [成功] Node.js已安装,版本: !NODE_VERSION! +:: Launch PowerShell script +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0install-and-run.ps1" %* -:: 检查package.json是否存在 -if not exist "package.json" ( - echo [错误] 未找到package.json文件 - echo 请确保在项目根目录下运行此脚本 - pause - exit /b 1 -) - -echo [成功] 找到package.json文件 - -:: 检查 pnpm 是否安装 -where pnpm >nul 2>&1 -if %errorlevel% equ 0 ( - set PKG_MANAGER=pnpm -) else ( - set PKG_MANAGER=npm -) - -echo [安装] 正在使用 !PKG_MANAGER! 安装/更新依赖... -echo 这可能需要几分钟时间,请耐心等待... -echo 正在执行: !PKG_MANAGER! install... - -call !PKG_MANAGER! install -if %errorlevel% neq 0 ( - echo [错误] 依赖安装失败 - echo 请检查网络连接或手动运行 '!PKG_MANAGER! install' - pause - exit /b 1 -) -echo [成功] 依赖安装/更新完成 - -:: 检查src目录和master.js是否存在 -if not exist "src\core\master.js" ( - echo [错误] 未找到src\core\master.js文件 - pause - exit /b 1 -) - -echo [成功] 项目文件检查完成 - -:: 启动应用程序 -echo. -echo ======================================== -echo 启动AIClient2API服务器... -echo ======================================== -echo. -echo 服务器将在 http://localhost:3000 启动 -echo 访问 http://localhost:3000 查看管理界面 -echo 按 Ctrl+C 停止服务器 -echo. - -:: 启动服务器 -node src\core\master.js +endlocal diff --git a/install-and-run.ps1 b/install-and-run.ps1 new file mode 100644 index 0000000..11821a0 --- /dev/null +++ b/install-and-run.ps1 @@ -0,0 +1,71 @@ +# install-and-run.ps1 +$OutputEncoding = [System.Text.Encoding]::UTF8 + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host " AI Client 2 API 快速安装启动脚本" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" + +# 处理参数 +$forcePull = $args -contains "--pull" + +# 检查 Git 并拉取 +if ($forcePull) { + Write-Host "[更新] 正在从远程仓库拉取最新代码..." + if (Get-Command git -ErrorAction SilentlyContinue) { + git pull + if ($LASTEXITCODE -ne 0) { + Write-Warning "Git pull 失败,请检查网络或手动处理冲突。" + } else { + Write-Host "[成功] 代码已更新。" -ForegroundColor Green + } + } else { + Write-Warning "未检测到 Git,跳过代码拉取。" + } +} + +# 检查 Node.js +Write-Host "[检查] 正在检查Node.js是否已安装..." +if (-not (Get-Command node -ErrorAction SilentlyContinue)) { + Write-Host "[错误] 未检测到Node.js,请先安装Node.js (https://nodejs.org/)" -ForegroundColor Red + Pause + exit 1 +} + +$nodeVersion = node --version +Write-Host "[成功] Node.js已安装,版本: $nodeVersion" -ForegroundColor Green + +# 检查 package.json +if (-not (Test-Path "package.json")) { + Write-Host "[错误] 未找到package.json文件,请确保在项目根目录下运行此脚本" -ForegroundColor Red + Pause + exit 1 +} + +# 确定包管理器 +$pkgManager = if (Get-Command pnpm -ErrorAction SilentlyContinue) { "pnpm" } else { "npm" } +Write-Host "[安装] 正在使用 $pkgManager 安装/更新依赖..." -ForegroundColor Cyan + +& $pkgManager install +if ($LASTEXITCODE -ne 0) { + Write-Host "[错误] 依赖安装失败,请检查网络连接。" -ForegroundColor Red + Pause + exit 1 +} + +# 检查主文件 +if (-not (Test-Path "src\core\master.js")) { + Write-Host "[错误] 未找到 src\core\master.js 文件" -ForegroundColor Red + Pause + exit 1 +} + +Write-Host "" +Write-Host "========================================" -ForegroundColor Green +Write-Host " 启动 AIClient2API 服务器..." -ForegroundColor Green +Write-Host "========================================" -ForegroundColor Green +Write-Host "服务器将在 http://localhost:3000 启动" +Write-Host "按 Ctrl+C 停止服务器" +Write-Host "" + +node src\core\master.js diff --git a/src/core/config-manager.js b/src/core/config-manager.js index 4452935..3514f68 100644 --- a/src/core/config-manager.js +++ b/src/core/config-manager.js @@ -54,50 +54,58 @@ function normalizeConfiguredProviders(config) { * @returns {Object} The initialized configuration object. */ export async function initializeConfig(args = process.argv.slice(2), configFilePath = 'configs/config.json') { - let currentConfig = {}; + const defaultConfig = { + REQUIRED_API_KEY: "123456", + SERVER_PORT: 3000, + HOST: '0.0.0.0', + MODEL_PROVIDER: MODEL_PROVIDER.GEMINI_CLI, + SYSTEM_PROMPT_FILE_PATH: INPUT_SYSTEM_PROMPT_FILE, // Default value + SYSTEM_PROMPT_MODE: 'append', + PROXY_URL: null, // HTTP/HTTPS/SOCKS5 代理地址,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080 + PROXY_ENABLED_PROVIDERS: [], // 启用代理的提供商列表,如 ['gemini-cli-oauth', 'claude-kiro-oauth'] + PROMPT_LOG_BASE_NAME: "prompt_log", + PROMPT_LOG_MODE: "none", + REQUEST_MAX_RETRIES: 3, + REQUEST_BASE_DELAY: 1000, + CREDENTIAL_SWITCH_MAX_RETRIES: 5, // 坏凭证切换最大重试次数(用于认证错误后切换凭证) + CRON_NEAR_MINUTES: 15, + CRON_REFRESH_TOKEN: false, + LOGIN_EXPIRY: 3600, // 登录过期时间(秒),默认1小时 + LOGIN_MAX_ATTEMPTS: 5, // 最大失败重试次数 + LOGIN_LOCKOUT_DURATION: 1800, // 锁定持续时间(秒),默认30分钟 + LOGIN_MIN_INTERVAL: 5000, // 两次尝试之间的最小间隔(毫秒),默认1秒 + PROVIDER_POOLS_FILE_PATH: null, // 新增号池配置文件路径 + MAX_ERROR_COUNT: 10, // 提供商最大错误次数 + providerFallbackChain: {}, // 跨类型 Fallback 链配置 + LOG_ENABLED: true, + LOG_OUTPUT_MODE: "all", + LOG_LEVEL: "info", + LOG_DIR: "logs", + LOG_INCLUDE_REQUEST_ID: true, + LOG_INCLUDE_TIMESTAMP: true, + LOG_MAX_FILE_SIZE: 10485760, + LOG_MAX_FILES: 10, + TLS_SIDECAR_ENABLED: false, // 启用 Go uTLS sidecar(需要编译 tls-sidecar 二进制) + TLS_SIDECAR_PORT: 9090, // sidecar 监听端口 + TLS_SIDECAR_BINARY_PATH: null // 自定义二进制路径(默认自动搜索) + }; + + let currentConfig = { ...defaultConfig }; try { const configData = fs.readFileSync(configFilePath, 'utf8'); - currentConfig = JSON.parse(configData); + const loadedConfig = JSON.parse(configData); + Object.assign(currentConfig, loadedConfig); logger.info('[Config] Loaded configuration from configs/config.json'); } catch (error) { - logger.error('[Config Error] Failed to load configs/config.json:', error.message); - // Fallback to default values if config.json is not found or invalid - currentConfig = { - REQUIRED_API_KEY: "123456", - SERVER_PORT: 3000, - HOST: '0.0.0.0', - MODEL_PROVIDER: MODEL_PROVIDER.GEMINI_CLI, - SYSTEM_PROMPT_FILE_PATH: INPUT_SYSTEM_PROMPT_FILE, // Default value - SYSTEM_PROMPT_MODE: 'append', - PROXY_URL: null, // HTTP/HTTPS/SOCKS5 代理地址,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080 - PROXY_ENABLED_PROVIDERS: [], // 启用代理的提供商列表,如 ['gemini-cli-oauth', 'claude-kiro-oauth'] - PROMPT_LOG_BASE_NAME: "prompt_log", - PROMPT_LOG_MODE: "none", - REQUEST_MAX_RETRIES: 3, - REQUEST_BASE_DELAY: 1000, - CREDENTIAL_SWITCH_MAX_RETRIES: 5, // 坏凭证切换最大重试次数(用于认证错误后切换凭证) - CRON_NEAR_MINUTES: 15, - CRON_REFRESH_TOKEN: false, - LOGIN_EXPIRY: 3600, // 登录过期时间(秒),默认1小时 - PROVIDER_POOLS_FILE_PATH: null, // 新增号池配置文件路径 - MAX_ERROR_COUNT: 10, // 提供商最大错误次数 - providerFallbackChain: {}, // 跨类型 Fallback 链配置 - LOG_ENABLED: true, - LOG_OUTPUT_MODE: "all", - LOG_LEVEL: "info", - LOG_DIR: "logs", - LOG_INCLUDE_REQUEST_ID: true, - LOG_INCLUDE_TIMESTAMP: true, - LOG_MAX_FILE_SIZE: 10485760, - LOG_MAX_FILES: 10, - TLS_SIDECAR_ENABLED: false, // 启用 Go uTLS sidecar(需要编译 tls-sidecar 二进制) - TLS_SIDECAR_PORT: 9090, // sidecar 监听端口 - TLS_SIDECAR_BINARY_PATH: null // 自定义二进制路径(默认自动搜索) - }; - logger.info('[Config] Using default configuration.'); + if (error.code !== 'ENOENT') { + logger.error('[Config Error] Failed to load configs/config.json:', error.message); + } else { + logger.info('[Config] configs/config.json not found, using default configuration.'); + } } + // CLI argument definitions: { flag, configKey, type, validValues? } // type: 'string' | 'int' | 'bool' | 'enum' const cliArgDefs = [ @@ -113,6 +121,9 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP { flag: '--cron-refresh-token', configKey: 'CRON_REFRESH_TOKEN', type: 'bool' }, { flag: '--provider-pools-file', configKey: 'PROVIDER_POOLS_FILE_PATH', type: 'string' }, { flag: '--max-error-count', configKey: 'MAX_ERROR_COUNT', type: 'int' }, + { flag: '--login-max-attempts', configKey: 'LOGIN_MAX_ATTEMPTS', type: 'int' }, + { flag: '--login-lockout-duration', configKey: 'LOGIN_LOCKOUT_DURATION', type: 'int' }, + { flag: '--login-min-interval', configKey: 'LOGIN_MIN_INTERVAL', type: 'int' }, ]; // Parse command-line arguments using definitions diff --git a/src/handlers/request-handler.js b/src/handlers/request-handler.js index c54fd1f..ebc84da 100644 --- a/src/handlers/request-handler.js +++ b/src/handlers/request-handler.js @@ -50,218 +50,222 @@ export function createRequestHandler(config, providerPoolManager) { // Generate unique request ID and set it in logger context const clientIp = getClientIp(req); const requestId = `${clientIp}:${generateRequestId()}`; - logger.setRequestContext(requestId); - // Deep copy the config for each request to allow dynamic modification - const currentConfig = deepmerge({}, config); - - // 计算当前请求的基础 URL - const protocol = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'; - const host = req.headers.host; - currentConfig.requestBaseUrl = `${protocol}://${host}`; - - const requestUrl = new URL(req.url, `http://${req.headers.host}`); - let path = requestUrl.pathname; - const method = req.method; + return logger.runWithContext(requestId, async () => { + // Deep copy the config for each request to allow dynamic modification + const currentConfig = deepmerge({}, config); + + // 计算当前请求的基础 URL + const protocol = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'; + const host = req.headers.host; + currentConfig.requestBaseUrl = `${protocol}://${host}`; + + const requestUrl = new URL(req.url, `http://${req.headers.host}`); + let path = requestUrl.pathname; + const method = req.method; - // Set CORS headers for all requests - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, PATCH'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-goog-api-key, Model-Provider, X-Requested-With, Accept, Origin'); - res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours cache for preflight - - // Handle CORS preflight requests - if (method === 'OPTIONS') { - res.writeHead(204); - res.end(); - return; - } - - // Serve static files for UI (除了登录页面需要认证) - // 检查是否是插件静态文件 - const pluginManager = getPluginManager(); - const isPluginStatic = pluginManager.isPluginStaticPath(path); - if (path.startsWith('/static/') || path === '/' || path === '/favicon.ico' || path === '/index.html' || path.startsWith('/app/') || path.startsWith('/components/') || path === '/login.html' || isPluginStatic) { - const served = await serveStaticFiles(path, res); - if (served) return; - } - - // 执行插件路由 - const pluginRouteHandled = await pluginManager.executeRoutes(method, path, req, res); - if (pluginRouteHandled) return; - - const uiHandled = await handleUIApiRequests(method, path, req, res, currentConfig, providerPoolManager); - if (uiHandled) return; - - // logger.info(`\n${new Date().toLocaleString()}`); - logger.info(`[Server] Received request: ${req.method} http://${req.headers.host}${req.url}`); - - // Health check endpoint - if (method === 'GET' && path === '/health') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - status: 'healthy', - timestamp: new Date().toISOString(), - provider: currentConfig.MODEL_PROVIDER - })); - return true; - } - - // Grok assets proxy endpoint - if (method === 'GET' && path === '/api/grok/assets') { - await handleGrokAssetsProxy(req, res, currentConfig, providerPoolManager); - return true; - } - - // providers health endpoint - // url params: provider[string], customName[string], unhealthRatioThreshold[float] - // 支持provider, customName过滤记录 - // 支持unhealthRatioThreshold控制不健康比例的阈值, 当unhealthyRatio超过阈值返回summaryHealthy: false - if (method === 'GET' && path === '/provider_health') { try { - const provider = requestUrl.searchParams.get('provider'); - const customName = requestUrl.searchParams.get('customName'); - let unhealthRatioThreshold = requestUrl.searchParams.get('unhealthRatioThreshold'); - unhealthRatioThreshold = unhealthRatioThreshold === null ? 0.0001 : parseFloat(unhealthRatioThreshold); - let provideStatus = await getProviderStatus(currentConfig, { provider, customName }); - let summaryHealth = true; - if (!isNaN(unhealthRatioThreshold)) { - summaryHealth = provideStatus.unhealthyRatio <= unhealthRatioThreshold; + // Set CORS headers for all requests + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, PATCH'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-goog-api-key, Model-Provider, X-Requested-With, Accept, Origin'); + res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours cache for preflight + + // Handle CORS preflight requests + if (method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + // Serve static files for UI (除了登录页面需要认证) + // 检查是否是插件静态文件 + const pluginManager = getPluginManager(); + const isPluginStatic = pluginManager.isPluginStaticPath(path); + if (path.startsWith('/static/') || path === '/' || path === '/favicon.ico' || path === '/index.html' || path.startsWith('/app/') || path.startsWith('/components/') || path === '/login.html' || isPluginStatic) { + const served = await serveStaticFiles(path, res); + if (served) return; + } + + // 执行插件路由 + const pluginRouteHandled = await pluginManager.executeRoutes(method, path, req, res); + if (pluginRouteHandled) return; + + const uiHandled = await handleUIApiRequests(method, path, req, res, currentConfig, providerPoolManager); + if (uiHandled) return; + + // logger.info(`\n${new Date().toLocaleString()}`); + logger.info(`[Server] Received request: ${req.method} http://${req.headers.host}${req.url}`); + + // Health check endpoint + if (method === 'GET' && path === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'healthy', + timestamp: new Date().toISOString(), + provider: currentConfig.MODEL_PROVIDER + })); + return true; + } + + // Grok assets proxy endpoint + if (method === 'GET' && path === '/api/grok/assets') { + await handleGrokAssetsProxy(req, res, currentConfig, providerPoolManager); + return true; + } + + // providers health endpoint + // url params: provider[string], customName[string], unhealthRatioThreshold[float] + // 支持provider, customName过滤记录 + // 支持unhealthRatioThreshold控制不健康比例的阈值, 当unhealthyRatio超过阈值返回summaryHealthy: false + if (method === 'GET' && path === '/provider_health') { + try { + const provider = requestUrl.searchParams.get('provider'); + const customName = requestUrl.searchParams.get('customName'); + let unhealthRatioThreshold = requestUrl.searchParams.get('unhealthRatioThreshold'); + unhealthRatioThreshold = unhealthRatioThreshold === null ? 0.0001 : parseFloat(unhealthRatioThreshold); + let provideStatus = await getProviderStatus(currentConfig, { provider, customName }); + let summaryHealth = true; + if (!isNaN(unhealthRatioThreshold)) { + summaryHealth = provideStatus.unhealthyRatio <= unhealthRatioThreshold; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + timestamp: new Date().toISOString(), + items: provideStatus.providerPoolsSlim, + count: provideStatus.count, + unhealthyCount: provideStatus.unhealthyCount, + unhealthyRatio: provideStatus.unhealthyRatio, + unhealthySummeryMessage: provideStatus.unhealthySummeryMessage, + summaryHealth + })); + return true; + } catch (error) { + logger.info(`[Server] req provider_health error: ${error.message}`); + handleError(res, { statusCode: 500, message: `Failed to get providers health: ${error.message}` }, currentConfig.MODEL_PROVIDER); + return; + } } - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - timestamp: new Date().toISOString(), - items: provideStatus.providerPoolsSlim, - count: provideStatus.count, - unhealthyCount: provideStatus.unhealthyCount, - unhealthyRatio: provideStatus.unhealthyRatio, - unhealthySummeryMessage: provideStatus.unhealthySummeryMessage, - summaryHealth - })); - return true; - } catch (error) { - logger.info(`[Server] req provider_health error: ${error.message}`); - handleError(res, { statusCode: 500, message: `Failed to get providers health: ${error.message}` }, currentConfig.MODEL_PROVIDER); - return; - } - } - // Handle API requests - // Allow overriding MODEL_PROVIDER via request header - const modelProviderHeader = req.headers['model-provider']; - if (modelProviderHeader) { - const registeredProviders = getRegisteredProviders(); - if (registeredProviders.includes(modelProviderHeader)) { - currentConfig.MODEL_PROVIDER = modelProviderHeader; - logger.info(`[Config] MODEL_PROVIDER overridden by header to: ${currentConfig.MODEL_PROVIDER}`); - } else { - logger.warn(`[Config] Provider ${modelProviderHeader} in header is not available.`); - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: `Provider ${modelProviderHeader} is not available.` } })); - return; - } - } - - // Check if the first path segment matches a MODEL_PROVIDER and switch if it does - const pathSegments = path.split('/').filter(segment => segment.length > 0); - - if (pathSegments.length > 0) { - const firstSegment = pathSegments[0]; - const registeredProviders = getRegisteredProviders(); - const isValidProvider = registeredProviders.includes(firstSegment); - const isAutoMode = firstSegment === MODEL_PROVIDER.AUTO; + // Handle API requests + // Allow overriding MODEL_PROVIDER via request header + const modelProviderHeader = req.headers['model-provider']; + if (modelProviderHeader) { + const registeredProviders = getRegisteredProviders(); + if (registeredProviders.includes(modelProviderHeader)) { + currentConfig.MODEL_PROVIDER = modelProviderHeader; + logger.info(`[Config] MODEL_PROVIDER overridden by header to: ${currentConfig.MODEL_PROVIDER}`); + } else { + logger.warn(`[Config] Provider ${modelProviderHeader} in header is not available.`); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: `Provider ${modelProviderHeader} is not available.` } })); + return; + } + } + + // Check if the first path segment matches a MODEL_PROVIDER and switch if it does + const pathSegments = path.split('/').filter(segment => segment.length > 0); + + if (pathSegments.length > 0) { + const firstSegment = pathSegments[0]; + const registeredProviders = getRegisteredProviders(); + const isValidProvider = registeredProviders.includes(firstSegment); + const isAutoMode = firstSegment === MODEL_PROVIDER.AUTO; - if (firstSegment && (isValidProvider || isAutoMode)) { - currentConfig.MODEL_PROVIDER = firstSegment; - logger.info(`[Config] MODEL_PROVIDER overridden by path segment to: ${currentConfig.MODEL_PROVIDER}`); - pathSegments.shift(); - path = '/' + pathSegments.join('/'); - requestUrl.pathname = path; - } else if (firstSegment && Object.values(MODEL_PROVIDER).includes(firstSegment)) { - // 如果在 MODEL_PROVIDER 中但没注册适配器,拦截并报错 - logger.warn(`[Config] Provider ${firstSegment} is recognized but no adapter is registered.`); - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: `Provider ${firstSegment} is not available.` } })); - return; - } else if (firstSegment && !isValidProvider) { - logger.info(`[Config] Ignoring invalid MODEL_PROVIDER in path segment: ${firstSegment}`); - } - } + if (firstSegment && (isValidProvider || isAutoMode)) { + currentConfig.MODEL_PROVIDER = firstSegment; + logger.info(`[Config] MODEL_PROVIDER overridden by path segment to: ${currentConfig.MODEL_PROVIDER}`); + pathSegments.shift(); + path = '/' + pathSegments.join('/'); + requestUrl.pathname = path; + } else if (firstSegment && Object.values(MODEL_PROVIDER).includes(firstSegment)) { + // 如果在 MODEL_PROVIDER 中但没注册适配器,拦截并报错 + logger.warn(`[Config] Provider ${firstSegment} is recognized but no adapter is registered.`); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: `Provider ${firstSegment} is not available.` } })); + return; + } else if (firstSegment && !isValidProvider) { + logger.info(`[Config] Ignoring invalid MODEL_PROVIDER in path segment: ${firstSegment}`); + } + } - // 1. 执行认证流程(只有 type='auth' 的插件参与) - const authResult = await pluginManager.executeAuth(req, res, requestUrl, currentConfig); - if (authResult.handled) { - // 认证插件已处理请求(如发送了错误响应) - return; - } - if (!authResult.authorized) { - // 没有认证插件授权,返回 401 - res.writeHead(401, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Unauthorized: API key is invalid or missing.' } })); - return; - } - - // 2. 执行普通中间件(type!='auth' 的插件) - const middlewareResult = await pluginManager.executeMiddleware(req, res, requestUrl, currentConfig); - if (middlewareResult.handled) { - // 中间件已处理请求 - return; - } + // 1. 执行认证流程(只有 type='auth' 的插件参与) + const authResult = await pluginManager.executeAuth(req, res, requestUrl, currentConfig); + if (authResult.handled) { + // 认证插件已处理请求(如发送了错误响应) + return; + } + if (!authResult.authorized) { + // 没有认证插件授权,返回 401 + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Unauthorized: API key is invalid or missing.' } })); + return; + } + + // 2. 执行普通中间件(type!='auth' 的插件) + const middlewareResult = await pluginManager.executeMiddleware(req, res, requestUrl, currentConfig); + if (middlewareResult.handled) { + // 中间件已处理请求 + return; + } - // Handle count_tokens requests (Anthropic API compatible) - if (path.includes('/count_tokens') && method === 'POST') { - try { - const body = await parseRequestBody(req); - logger.info(`[Server] Handling count_tokens request for model: ${body.model}`); + // Handle count_tokens requests (Anthropic API compatible) + if (path.includes('/count_tokens') && method === 'POST') { + try { + const body = await parseRequestBody(req); + logger.info(`[Server] Handling count_tokens request for model: ${body.model}`); + + // Use common utility method directly + try { + const result = countTokensAnthropic(body); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + } catch (tokenError) { + logger.warn(`[Server] Common countTokens failed, falling back: ${tokenError.message}`); + // Last resort: return 0 + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ input_tokens: 0 })); + } + return true; + } catch (error) { + logger.error(`[Server] count_tokens error: ${error.message}`); + handleError(res, { statusCode: 500, message: `Failed to count tokens: ${error.message}` }, currentConfig.MODEL_PROVIDER); + return; + } + } + + // 获取或选择 API Service 实例 + let apiService; + // try { + // apiService = await getApiService(currentConfig); + // } catch (error) { + // handleError(res, { statusCode: 500, message: `Failed to get API service: ${error.message}` }, currentConfig.MODEL_PROVIDER); + // const poolManager = getProviderPoolManager(); + // if (poolManager) { + // poolManager.markProviderUnhealthy(currentConfig.MODEL_PROVIDER, { + // uuid: currentConfig.uuid + // }); + // } + // return; + // } - // Use common utility method directly try { - const result = countTokensAnthropic(body); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(result)); - } catch (tokenError) { - logger.warn(`[Server] Common countTokens failed, falling back: ${tokenError.message}`); - // Last resort: return 0 - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ input_tokens: 0 })); + // Handle API requests + const apiHandled = await handleAPIRequests(method, path, req, res, currentConfig, apiService, providerPoolManager, PROMPT_LOG_FILENAME); + if (apiHandled) return; + + // Fallback for unmatched routes + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Not Found' } })); + } catch (error) { + handleError(res, error, currentConfig.MODEL_PROVIDER); } - return true; - } catch (error) { - logger.error(`[Server] count_tokens error: ${error.message}`); - handleError(res, { statusCode: 500, message: `Failed to count tokens: ${error.message}` }, currentConfig.MODEL_PROVIDER); - return; + } finally { + // Clear request context after request is complete + logger.clearRequestContext(requestId); } - } - - // 获取或选择 API Service 实例 - let apiService; - // try { - // apiService = await getApiService(currentConfig); - // } catch (error) { - // handleError(res, { statusCode: 500, message: `Failed to get API service: ${error.message}` }, currentConfig.MODEL_PROVIDER); - // const poolManager = getProviderPoolManager(); - // if (poolManager) { - // poolManager.markProviderUnhealthy(currentConfig.MODEL_PROVIDER, { - // uuid: currentConfig.uuid - // }); - // } - // return; - // } - - try { - // Handle API requests - const apiHandled = await handleAPIRequests(method, path, req, res, currentConfig, apiService, providerPoolManager, PROMPT_LOG_FILENAME); - if (apiHandled) return; - - // Fallback for unmatched routes - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Not Found' } })); - } catch (error) { - handleError(res, error, currentConfig.MODEL_PROVIDER); - } finally { - // Clear request context after request is complete - logger.clearRequestContext(requestId); - } + }); }; + } diff --git a/src/plugins/api-potluck/api-routes.js b/src/plugins/api-potluck/api-routes.js index 7703e69..dd76120 100644 --- a/src/plugins/api-potluck/api-routes.js +++ b/src/plugins/api-potluck/api-routes.js @@ -1,6 +1,6 @@ /** * API 大锅饭 - 管理 API 路由 - * 提供 Key 管理的 RESTful API 和用户端查询 API + * 提供 Key 管理的 RESTful API */ import { @@ -16,30 +16,10 @@ import { getStats, validateKey, KEY_PREFIX, - setConfigGetter, - updateBonusRemaining, applyDailyLimitToAllKeys, getAllKeyIds } from './key-manager.js'; -import { - getUserCredentials, - addUserCredential, - migrateUserCredentials, - getAllUsersCredentials, - syncCredentialBonuses, - getBonusDetails, - getConfig, - updateConfig, - getAllUserApiKeys -} from './user-data-manager.js'; -import path from 'path'; import logger from '../../utils/logger.js'; -import { existsSync } from 'fs'; -import { promises as fs } from 'fs'; -import multer from 'multer'; -import { batchImportKiroRefreshTokensStream, importAwsCredentials } from '../../auth/oauth-handlers.js'; -import { autoLinkProviderConfigs, getProviderPoolManager } from '../../services/service-manager.js'; -import { CONFIG } from '../../core/config-manager.js'; /** * 解析请求体 @@ -56,7 +36,7 @@ function parseRequestBody(req) { try { resolve(body ? JSON.parse(body) : {}); } catch (error) { - reject(new Error('Invalid JSON format')); + reject(new Error('JSON 格式无效')); } }); req.on('error', reject); @@ -138,7 +118,7 @@ export async function handlePotluckApiRoutes(method, path, req, res) { if (!isAuthed) { sendJson(res, 401, { success: false, - error: { message: 'Unauthorized: Please login first', code: 'UNAUTHORIZED' } + error: { message: '未授权:请先登录', code: 'UNAUTHORIZED' } }); return true; } @@ -155,112 +135,28 @@ export async function handlePotluckApiRoutes(method, path, req, res) { if (method === 'GET' && path === '/api/potluck/keys') { const keys = await listKeys(); const stats = await getStats(); - const config = getConfig(); sendJson(res, 200, { success: true, - data: { - keys, - stats, - config - } - }); - return true; - } - - // GET /api/potluck/config - 获取配置 - if (method === 'GET' && path === '/api/potluck/config') { - const config = getConfig(); - sendJson(res, 200, { - success: true, - data: config - }); - return true; - } - - // PUT /api/potluck/config - 更新配置 - if (method === 'PUT' && path === '/api/potluck/config') { - const body = await parseRequestBody(req); - const { defaultDailyLimit, bonusPerCredential, bonusValidityDays, persistInterval } = body; - - // 验证参数 - if (defaultDailyLimit !== undefined && (typeof defaultDailyLimit !== 'number' || defaultDailyLimit < 1)) { - sendJson(res, 400, { success: false, error: { message: 'defaultDailyLimit must be a positive number' } }); - return true; - } - if (bonusPerCredential !== undefined && (typeof bonusPerCredential !== 'number' || bonusPerCredential < 0)) { - sendJson(res, 400, { success: false, error: { message: 'bonusPerCredential must be a non-negative number' } }); - return true; - } - if (bonusValidityDays !== undefined && (typeof bonusValidityDays !== 'number' || bonusValidityDays < 1)) { - sendJson(res, 400, { success: false, error: { message: 'bonusValidityDays must be a positive number' } }); - return true; - } - if (persistInterval !== undefined && (typeof persistInterval !== 'number' || persistInterval < 1000)) { - sendJson(res, 400, { success: false, error: { message: 'persistInterval must be at least 1000ms' } }); - return true; - } - - const newConfig = await updateConfig({ defaultDailyLimit, bonusPerCredential, bonusValidityDays, persistInterval }); - sendJson(res, 200, { - success: true, - message: 'Config updated successfully', - data: newConfig + data: { keys, stats } }); return true; } // POST /api/potluck/keys/apply-limit - 批量应用每日限额到所有 Key if (method === 'POST' && path === '/api/potluck/keys/apply-limit') { - const config = getConfig(); - const result = await applyDailyLimitToAllKeys(config.defaultDailyLimit); - sendJson(res, 200, { - success: true, - message: `已将每日限额 ${config.defaultDailyLimit} 应用到 ${result.updated}/${result.total} 个 Key`, - data: result - }); - return true; - } - - // POST /api/potluck/keys/apply-bonus - 批量同步所有用户的资源包 - if (method === 'POST' && path === '/api/potluck/keys/apply-bonus') { - const allKeyIds = getAllKeyIds(); - let totalSynced = 0; - let totalBonusUpdated = 0; + const body = await parseRequestBody(req); + const { dailyLimit } = body; - for (const apiKey of allKeyIds) { - try { - // 获取用户凭据并检查健康状态 - const credentials = getUserCredentials(apiKey); - if (credentials.length === 0) continue; - - // 构建带健康状态的凭证列表(从主服务同步) - const credentialsWithHealth = []; - for (const cred of credentials) { - const healthResult = await syncCredentialHealthFromPool(apiKey, cred); - credentialsWithHealth.push({ - id: cred.id, - isHealthy: healthResult.isHealthy, - addedAt: cred.addedAt - }); - } - - // 同步资源包 - const bonusSync = await syncCredentialBonuses(apiKey, credentialsWithHealth); - await updateBonusRemaining(apiKey, bonusSync.bonusRemaining); - - totalSynced++; - if (bonusSync.added > 0 || bonusSync.removed > 0) { - totalBonusUpdated++; - } - } catch (error) { - logger.warn(`[API Potluck] Failed to sync bonus for ${apiKey.substring(0, 12)}...:`, error.message); - } + if (dailyLimit === undefined || typeof dailyLimit !== 'number' || dailyLimit < 1) { + sendJson(res, 400, { success: false, error: { message: 'dailyLimit 必须是一个正数' } }); + return true; } + const result = await applyDailyLimitToAllKeys(dailyLimit); sendJson(res, 200, { success: true, - message: `已同步 ${totalSynced} 个用户的资源包,${totalBonusUpdated} 个有变更`, - data: { totalKeys: allKeyIds.length, synced: totalSynced, updated: totalBonusUpdated } + message: `已将每日限额 ${dailyLimit} 应用到 ${result.updated}/${result.total} 个 Key`, + data: result }); return true; } @@ -270,10 +166,10 @@ export async function handlePotluckApiRoutes(method, path, req, res) { const body = await parseRequestBody(req); const { name, dailyLimit } = body; const keyData = await createKey(name, dailyLimit); - sendJson(res, 201, { - success: true, - message: 'API Key created successfully', - data: keyData + sendJson(res, 201, { + success: true, + message: 'API Key 创建成功', + data: keyData }); return true; } @@ -288,7 +184,7 @@ export async function handlePotluckApiRoutes(method, path, req, res) { if (method === 'GET' && !subPath) { const keyData = await getKey(keyId); if (!keyData) { - sendJson(res, 404, { success: false, error: { message: 'Key not found' } }); + sendJson(res, 404, { success: false, error: { message: '未找到 Key' } }); return true; } sendJson(res, 200, { success: true, data: keyData }); @@ -299,10 +195,10 @@ export async function handlePotluckApiRoutes(method, path, req, res) { if (method === 'DELETE' && !subPath) { const deleted = await deleteKey(keyId); if (!deleted) { - sendJson(res, 404, { success: false, error: { message: 'Key not found' } }); + sendJson(res, 404, { success: false, error: { message: '未找到 Key' } }); return true; } - sendJson(res, 200, { success: true, message: 'Key deleted successfully' }); + sendJson(res, 200, { success: true, message: 'Key 删除成功' }); return true; } @@ -314,19 +210,19 @@ export async function handlePotluckApiRoutes(method, path, req, res) { if (typeof dailyLimit !== 'number' || dailyLimit < 0) { sendJson(res, 400, { success: false, - error: { message: 'Invalid dailyLimit value' } + error: { message: '无效的每日限额值' } }); return true; } const keyData = await updateKeyLimit(keyId, dailyLimit); if (!keyData) { - sendJson(res, 404, { success: false, error: { message: 'Key not found' } }); + sendJson(res, 404, { success: false, error: { message: '未找到 Key' } }); return true; } sendJson(res, 200, { success: true, - message: 'Daily limit updated successfully', + message: '每日限额更新成功', data: keyData }); return true; @@ -336,12 +232,12 @@ export async function handlePotluckApiRoutes(method, path, req, res) { if (method === 'POST' && subPath === '/reset') { const keyData = await resetKeyUsage(keyId); if (!keyData) { - sendJson(res, 404, { success: false, error: { message: 'Key not found' } }); + sendJson(res, 404, { success: false, error: { message: '未找到 Key' } }); return true; } sendJson(res, 200, { success: true, - message: 'Usage reset successfully', + message: '使用量重置成功', data: keyData }); return true; @@ -351,12 +247,12 @@ export async function handlePotluckApiRoutes(method, path, req, res) { if (method === 'POST' && subPath === '/toggle') { const keyData = await toggleKey(keyId); if (!keyData) { - sendJson(res, 404, { success: false, error: { message: 'Key not found' } }); + sendJson(res, 404, { success: false, error: { message: '未找到 Key' } }); return true; } sendJson(res, 200, { success: true, - message: `Key ${keyData.enabled ? 'enabled' : 'disabled'} successfully`, + message: `Key 已成功${keyData.enabled ? '启用' : '禁用'}`, data: keyData }); return true; @@ -370,19 +266,19 @@ export async function handlePotluckApiRoutes(method, path, req, res) { if (!name || typeof name !== 'string') { sendJson(res, 400, { success: false, - error: { message: 'Invalid name value' } + error: { message: '无效的名称值' } }); return true; } const keyData = await updateKeyName(keyId, name); if (!keyData) { - sendJson(res, 404, { success: false, error: { message: 'Key not found' } }); + sendJson(res, 404, { success: false, error: { message: '未找到 Key' } }); return true; } sendJson(res, 200, { success: true, - message: 'Name updated successfully', + message: '名称更新成功', data: keyData }); return true; @@ -392,12 +288,12 @@ export async function handlePotluckApiRoutes(method, path, req, res) { if (method === 'POST' && subPath === '/regenerate') { const result = await regenerateKey(keyId); if (!result) { - sendJson(res, 404, { success: false, error: { message: 'Key not found' } }); + sendJson(res, 404, { success: false, error: { message: '未找到 Key' } }); return true; } sendJson(res, 200, { success: true, - message: 'Key regenerated successfully', + message: 'Key 重新生成成功', data: { oldKey: result.oldKey, newKey: result.newKey, @@ -409,14 +305,14 @@ export async function handlePotluckApiRoutes(method, path, req, res) { } // 未匹配的 potluck 路由 - sendJson(res, 404, { success: false, error: { message: 'Potluck API endpoint not found' } }); + sendJson(res, 404, { success: false, error: { message: '未找到 Potluck API 端点' } }); return true; } catch (error) { logger.error('[API Potluck] API error:', error); sendJson(res, 500, { success: false, - error: { message: error.message || 'Internal server error' } + error: { message: error.message || '内部服务器错误' } }); return true; } @@ -469,7 +365,7 @@ export async function handlePotluckUserApiRoutes(method, path, req, res) { sendJson(res, 401, { success: false, error: { - message: 'API Key required. Please provide your API Key in Authorization header (Bearer maki_xxx) or x-api-key header.', + message: '需要 API Key。请在 Authorization 标头 (Bearer maki_xxx) 或 x-api-key 标头中提供您的 API Key。', code: 'API_KEY_REQUIRED' } }); @@ -481,15 +377,15 @@ export async function handlePotluckUserApiRoutes(method, path, req, res) { if (!validation.valid && validation.reason !== 'quota_exceeded') { const errorMessages = { - 'invalid_format': 'Invalid API key format', - 'not_found': 'API key not found', - 'disabled': 'API key has been disabled' + 'invalid_format': 'API Key 格式无效', + 'not_found': '未找到 API Key', + 'disabled': 'API Key 已禁用' }; sendJson(res, 401, { success: false, error: { - message: errorMessages[validation.reason] || 'Invalid API key', + message: errorMessages[validation.reason] || '无效的 API Key', code: validation.reason } }); @@ -503,7 +399,7 @@ export async function handlePotluckUserApiRoutes(method, path, req, res) { if (!keyData) { sendJson(res, 404, { success: false, - error: { message: 'Key not found', code: 'KEY_NOT_FOUND' } + error: { message: '未找到 Key', code: 'KEY_NOT_FOUND' } }); return true; } @@ -513,11 +409,6 @@ export async function handlePotluckUserApiRoutes(method, path, req, res) { ? Math.round((keyData.todayUsage / keyData.dailyLimit) * 100) : 0; - // 获取资源包详情 - const bonusDetails = getBonusDetails(apiKey); - const bonusTotal = bonusDetails.bonuses.length * bonusDetails.bonusPerCredential; - const bonusUsed = bonusDetails.bonuses.reduce((sum, b) => sum + b.usedCount, 0); - // 返回用户友好的使用量信息(隐藏敏感信息) sendJson(res, 200, { success: true, @@ -531,134 +422,22 @@ export async function handlePotluckUserApiRoutes(method, path, req, res) { percent: usagePercent, resetDate: keyData.lastResetDate }, - bonusRemaining: keyData.bonusRemaining || 0, - bonusTotal: bonusTotal, - bonusUsed: bonusUsed, total: keyData.totalUsage, lastUsedAt: keyData.lastUsedAt, createdAt: keyData.createdAt, + usageHistory: keyData.usageHistory || {}, // 显示部分遮蔽的 Key ID + maskedKey: `${apiKey.substring(0, 12)}...${apiKey.substring(apiKey.length - 4)}` } }); return true; } - // POST /api/potluckuser/upload - 上传授权文件 - if (method === 'POST' && path === '/api/potluckuser/upload') { - return await handleUserUpload(req, res, apiKey); - } - - // POST /api/potluckuser/regenerate-key - 用户重置自己的 API Key - if (method === 'POST' && path === '/api/potluckuser/regenerate-key') { - const result = await regenerateKey(apiKey); - if (!result) { - sendJson(res, 404, { - success: false, - error: { message: 'Key not found' } - }); - return true; - } - - // 同时迁移用户的凭据数据到新 Key - await migrateUserCredentials(apiKey, result.newKey); - - sendJson(res, 200, { - success: true, - message: 'API Key regenerated successfully', - data: { - newKey: result.newKey, - maskedNewKey: `${result.newKey.substring(0, 12)}...${result.newKey.substring(result.newKey.length - 4)}` - } - }); - return true; - } - - // POST /api/potluckuser/kiro/batch-import-tokens - 批量导入 Kiro refresh token - if (method === 'POST' && path === '/api/potluckuser/kiro/batch-import-tokens') { - return await handleKiroBatchImportTokens(req, res, apiKey); - } - - // POST /api/potluckuser/kiro/import-aws-credentials - 导入 AWS SSO 凭据 - if (method === 'POST' && path === '/api/potluckuser/kiro/import-aws-credentials') { - return await handleKiroImportAwsCredentials(req, res, apiKey); - } - - // GET /api/potluckuser/credentials - 获取用户的凭据列表 - if (method === 'GET' && path === '/api/potluckuser/credentials') { - const credentials = getUserCredentials(apiKey); - const bonusDetails = getBonusDetails(apiKey); - - // 将资源包信息附加到对应凭证 - const credentialsWithBonus = credentials.map(cred => { - const bonus = bonusDetails.bonuses.find(b => b.credentialId === cred.id); - return { - ...cred, - bonus: bonus ? { - usedCount: bonus.usedCount, - remaining: bonus.remaining, - total: bonusDetails.bonusPerCredential, - expiresAt: bonus.expiresAt - } : null - }; - }); - - sendJson(res, 200, { - success: true, - data: credentialsWithBonus - }); - return true; - } - - // POST /api/potluckuser/credentials/check-all - 批量检查所有凭据健康状态 - if (method === 'POST' && path === '/api/potluckuser/credentials/check-all') { - const results = await checkUserCredentialsHealth(apiKey); - const credentials = getUserCredentials(apiKey); - const bonusDetails = getBonusDetails(apiKey); - - // 将资源包信息附加到对应凭证 - const credentialsWithBonus = credentials.map(cred => { - const healthResult = results.find(r => r.id === cred.id); - const bonus = bonusDetails.bonuses.find(b => b.credentialId === cred.id); - return { - ...cred, - isHealthy: healthResult?.isHealthy, - healthMessage: healthResult?.message, - bonus: bonus ? { - usedCount: bonus.usedCount, - remaining: bonus.remaining, - total: bonusDetails.bonusPerCredential, - expiresAt: bonus.expiresAt - } : null - }; - }); - - sendJson(res, 200, { - success: true, - data: { - results, - credentials: credentialsWithBonus - } - }); - return true; - } - - // 处理凭据相关的路由 - const credentialMatch = path.match(/^\/api\/potluckuser\/credentials\/([^\/]+)(\/.*)?$/); - if (credentialMatch) { - const credentialId = decodeURIComponent(credentialMatch[1]); - const subPath = credentialMatch[2] || ''; - - // POST /api/potluckuser/credentials/:id/health - 检查凭据健康状态 - if (method === 'POST' && subPath === '/health') { - return await handleCredentialHealthCheck(req, res, apiKey, credentialId); - } - } - // 未匹配的用户端路由 sendJson(res, 404, { success: false, - error: { message: 'User API endpoint not found' } + error: { message: '未找到用户 API 端点' } }); return true; @@ -666,537 +445,8 @@ export async function handlePotluckUserApiRoutes(method, path, req, res) { logger.error('[API Potluck] User API error:', error); sendJson(res, 500, { success: false, - error: { message: error.message || 'Internal server error' } + error: { message: error.message || '内部服务器错误' } }); return true; } } - -/** - * 提供商映射 - */ -const providerMap = { - 'gemini-cli-oauth': 'gemini', - 'gemini-antigravity': 'antigravity', - 'claude-kiro-oauth': 'kiro', - 'openai-qwen-oauth': 'qwen', - 'openai-iflow': 'iflow' -}; - -/** - * 配置 multer 用于用户上传 - */ -const userUploadStorage = multer.diskStorage({ - destination: async (req, file, cb) => { - try { - // 先使用临时目录 - const uploadPath = path.join(process.cwd(), 'configs', 'temp'); - await fs.mkdir(uploadPath, { recursive: true }); - cb(null, uploadPath); - } catch (error) { - cb(error); - } - }, - filename: (req, file, cb) => { - const timestamp = Date.now(); - const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_'); - cb(null, `${timestamp}_${sanitizedName}`); - } -}); - -const userUploadFileFilter = (req, file, cb) => { - const allowedTypes = ['.json', '.txt', '.key', '.pem', '.p12', '.pfx']; - const ext = path.extname(file.originalname).toLowerCase(); - if (allowedTypes.includes(ext)) { - cb(null, true); - } else { - cb(new Error('Unsupported file type'), false); - } -}; - -const userUpload = multer({ - storage: userUploadStorage, - fileFilter: userUploadFileFilter, - limits: { - fileSize: 5 * 1024 * 1024 // 5MB 限制 - } -}); - -/** - * 处理用户上传授权文件(带自动绑定和凭据关联功能) - * @param {http.IncomingMessage} req - * @param {http.ServerResponse} res - * @param {string} apiKey - 用户的 API Key - * @returns {Promise} - */ -async function handleUserUpload(req, res, apiKey) { - return new Promise((resolve) => { - userUpload.single('file')(req, res, async (err) => { - if (err) { - logger.error('[API Potluck User] File upload error:', err.message); - sendJson(res, 400, { success: false, error: err.message }); - resolve(true); - return; - } - - if (!req.file) { - sendJson(res, 400, { success: false, error: 'No file uploaded' }); - resolve(true); - return; - } - - try { - const providerType = req.body?.provider || 'common'; - const provider = providerMap[providerType] || providerType; - const tempFilePath = req.file.path; - - // 根据 provider 确定目标目录 - let targetDir = path.join(process.cwd(), 'configs', provider); - - // kiro 类型需要子文件夹 - if (provider === 'kiro') { - const timestamp = Date.now(); - const originalNameWithoutExt = path.parse(req.file.originalname).name; - const subFolder = `${timestamp}_${originalNameWithoutExt}`; - targetDir = path.join(targetDir, subFolder); - } - - await fs.mkdir(targetDir, { recursive: true }); - - const targetFilePath = path.join(targetDir, req.file.filename); - await fs.rename(tempFilePath, targetFilePath); - - const relativePath = path.relative(process.cwd(), targetFilePath).replace(/\\/g, '/'); - - // 将凭据关联到用户 - const credentialInfo = { - path: relativePath, - provider: providerType, - authMethod: 'file-upload' - }; - const credential = await addUserCredential(apiKey, credentialInfo); - - // 自动从主服务同步健康状态 - const healthResult = await syncCredentialHealthFromPool(apiKey, credential); - - // 触发自动绑定 - try { - await autoLinkProviderConfigs(CONFIG); - } catch (linkError) { - logger.warn('[API Potluck User] Auto-link failed:', linkError.message); - } - - logger.info(`[API Potluck User] File uploaded, linked and health checked: ${relativePath} (provider: ${providerType}, health: ${healthResult.message})`); - - sendJson(res, 200, { - success: true, - message: 'File uploaded successfully', - filePath: relativePath, - originalName: req.file.originalname, - provider: provider, - health: healthResult - }); - resolve(true); - - } catch (error) { - logger.error('[API Potluck User] File processing error:', error); - sendJson(res, 500, { success: false, error: error.message }); - resolve(true); - } - }); - }); -} - -/** - * 处理 Kiro 批量导入 Refresh Token - * @param {http.IncomingMessage} req - * @param {http.ServerResponse} res - * @param {string} apiKey - 用户的 API Key - */ -async function handleKiroBatchImportTokens(req, res, apiKey) { - try { - const body = await parseRequestBody(req); - const { refreshTokens, region } = body; - - if (!refreshTokens || !Array.isArray(refreshTokens) || refreshTokens.length === 0) { - sendJson(res, 400, { - success: false, - error: 'refreshTokens array is required and must not be empty' - }); - return true; - } - - logger.info(`[API Potluck User] Starting batch import of ${refreshTokens.length} tokens (user: ${apiKey.substring(0, 12)}...)`); - - // 设置 SSE 响应头 - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'X-Accel-Buffering': 'no' - }); - - // 发送 SSE 事件的辅助函数 - const sendSSE = (event, data) => { - res.write(`event: ${event}\n`); - res.write(`data: ${JSON.stringify(data)}\n\n`); - }; - - // 发送开始事件 - sendSSE('start', { total: refreshTokens.length }); - - // 执行流式批量导入 - const result = await batchImportKiroRefreshTokensStream( - refreshTokens, - region || 'us-east-1', - async (progress) => { - // 每处理完一个 token 发送进度更新 - sendSSE('progress', progress); - - // 成功的凭据关联到用户并执行健康检查 - if (progress.current && progress.current.success && progress.current.path) { - try { - const credentialInfo = { - path: progress.current.path.replace(/\\/g, '/'), - provider: 'claude-kiro-oauth', - authMethod: 'refresh-token' - }; - const credential = await addUserCredential(apiKey, credentialInfo); - - // 自动从主服务同步健康状态 - await syncCredentialHealthFromPool(apiKey, credential); - logger.info(`[API Potluck User] Credential linked and health synced: ${credentialInfo.path}`); - } catch (linkError) { - logger.warn('[API Potluck User] Failed to link/check credential:', linkError.message); - } - } - } - ); - - logger.info(`[API Potluck User] Completed: ${result.success} success, ${result.failed} failed`); - - // 发送完成事件 - sendSSE('complete', { - success: true, - total: result.total, - successCount: result.success, - failedCount: result.failed, - details: result.details - }); - - res.end(); - return true; - - } catch (error) { - logger.error('[API Potluck User] Kiro Batch Import Error:', error); - if (res.headersSent) { - res.write(`event: error\n`); - res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`); - res.end(); - } else { - sendJson(res, 500, { - success: false, - error: error.message - }); - } - return true; - } -} - -/** - * 处理 Kiro 导入 AWS 凭据 - * @param {http.IncomingMessage} req - * @param {http.ServerResponse} res - * @param {string} apiKey - 用户的 API Key - */ -async function handleKiroImportAwsCredentials(req, res, apiKey) { - try { - const body = await parseRequestBody(req); - const { credentials } = body; - - if (!credentials || typeof credentials !== 'object') { - sendJson(res, 400, { - success: false, - error: 'credentials object is required' - }); - return true; - } - - // 验证必需字段 - const missingFields = []; - if (!credentials.clientId) missingFields.push('clientId'); - if (!credentials.clientSecret) missingFields.push('clientSecret'); - if (!credentials.accessToken) missingFields.push('accessToken'); - if (!credentials.refreshToken) missingFields.push('refreshToken'); - - if (missingFields.length > 0) { - sendJson(res, 400, { - success: false, - error: `Missing required fields: ${missingFields.join(', ')}` - }); - return true; - } - - logger.info(`[API Potluck User] Starting AWS credentials import (user: ${apiKey.substring(0, 12)}...)`); - - const result = await importAwsCredentials(credentials); - - if (result.success) { - logger.info(`[API Potluck User] Successfully imported credentials to: ${result.path}`); - - // 将凭据路径关联到用户 - const credentialInfo = { - path: result.path, - provider: 'claude-kiro-oauth', - authMethod: credentials.authMethod || 'builder-id' - }; - const credential = await addUserCredential(apiKey, credentialInfo); - - // 自动从主服务同步健康状态 - const healthResult = await syncCredentialHealthFromPool(apiKey, credential); - logger.info(`[API Potluck User] Health sync result: ${healthResult.message}`); - - sendJson(res, 200, { - success: true, - path: result.path, - message: 'AWS credentials imported successfully', - health: healthResult - }); - } else { - const statusCode = result.error === 'duplicate' ? 409 : 500; - sendJson(res, statusCode, { - success: false, - error: result.error, - existingPath: result.existingPath || null - }); - } - return true; - - } catch (error) { - logger.error('[API Potluck User] Kiro AWS Import Error:', error); - sendJson(res, 500, { - success: false, - error: error.message - }); - return true; - } -} - -/** - * 从主服务同步凭据健康状态(不触发实际检查,不存储到本地) - * @param {string} apiKey - 用户的 API Key(保留参数以兼容调用) - * @param {Object} credential - 凭据对象 - * @returns {Promise<{isHealthy: boolean|null, message: string}>} - */ -async function syncCredentialHealthFromPool(apiKey, credential) { - const fullPath = path.join(process.cwd(), credential.path); - - // 检查文件是否存在 - if (!existsSync(fullPath)) { - return { isHealthy: false, message: '凭据文件不存在' }; - } - - // 从 ProviderPoolManager 获取该凭据对应的 provider 状态 - const poolManager = getProviderPoolManager(); - if (poolManager && credential.provider) { - // 在 providerStatus 中查找匹配的配置 - const providerPool = poolManager.providerStatus[credential.provider]; - if (providerPool && providerPool.length > 0) { - // 通过凭据路径匹配 provider 配置 - const normalizedCredPath = credential.path.replace(/\\/g, '/'); - const matchedProvider = providerPool.find(p => { - const configPath = p.config.kiroOAuthCredsFile || p.config.oauthCredsFile || ''; - const normalizedConfigPath = configPath.replace(/\\/g, '/'); - return normalizedConfigPath === normalizedCredPath || - normalizedConfigPath.endsWith(normalizedCredPath) || - normalizedCredPath.endsWith(normalizedConfigPath); - }); - - if (matchedProvider) { - const config = matchedProvider.config; - const isHealthy = config.isHealthy && !config.isDisabled; - let message = '健康检查:正常'; - - if (config.isDisabled) { - message = '已禁用'; - } else if (!config.isHealthy) { - message = config.lastErrorMessage || '健康检查:异常'; - } - - return { isHealthy, message }; - } - } - } - - // 未在主服务中找到匹配的配置,检查文件有效性 - try { - const content = await fs.readFile(fullPath, 'utf8'); - const credData = JSON.parse(content); - - // 检查 expiresAt 字段 - if (credData.expiresAt) { - const expiresAt = new Date(credData.expiresAt); - const now = new Date(); - - if (expiresAt < now) { - return { isHealthy: false, message: '凭据已过期' }; - } - } - - // 文件存在且未过期,但未在主服务中注册 - return { isHealthy: null, message: '未注册到服务' }; - - } catch (parseError) { - return { isHealthy: false, message: '凭据文件格式错误' }; - } -} - -/** - * 处理凭据健康检查 - * @param {http.IncomingMessage} req - * @param {http.ServerResponse} res - * @param {string} apiKey - 用户的 API Key - * @param {string} credentialId - 凭据 ID - */ -async function handleCredentialHealthCheck(req, res, apiKey, credentialId) { - try { - const credentials = getUserCredentials(apiKey); - const credential = credentials.find(c => c.id === credentialId); - - if (!credential) { - sendJson(res, 404, { - success: false, - error: { message: 'Credential not found' } - }); - return true; - } - - logger.info(`[API Potluck User] Syncing health for credential: ${credential.path}`); - - const result = await syncCredentialHealthFromPool(apiKey, credential); - - sendJson(res, 200, { - success: true, - data: result - }); - return true; - - } catch (error) { - logger.error('[API Potluck User] Health check error:', error); - sendJson(res, 500, { - success: false, - error: error.message - }); - return true; - } -} - - -// ============ 定时健康检查 ============ - -const HEALTH_CHECK_INTERVAL = 5 * 60 * 1000; // 5 分钟 -let healthCheckTimer = null; - -/** - * 批量同步所有用户的凭据健康状态(从主服务同步) - * @returns {Promise<{total: number, checked: number, healthy: number, unhealthy: number}>} - */ -async function checkAllCredentialsHealth() { - const allUsers = getAllUsersCredentials(); - let total = 0, checked = 0, healthy = 0, unhealthy = 0; - - for (const { apiKey, credentials } of allUsers) { - for (const credential of credentials) { - total++; - try { - const result = await syncCredentialHealthFromPool(apiKey, credential); - checked++; - if (result.isHealthy) { - healthy++; - } else if (result.isHealthy === false) { - unhealthy++; - } - // isHealthy === null 表示未注册到服务,不计入健康/不健康 - } catch (error) { - logger.warn(`[API Potluck] Health sync failed for ${credential.path}:`, error.message); - } - } - } - - return { total, checked, healthy, unhealthy }; -} - -/** - * 同步单个用户的所有凭据健康状态(从主服务同步) - * 同时更新资源包状态和 Key 的 bonusRemaining - * @param {string} apiKey - 用户的 API Key - * @returns {Promise>} - */ -async function checkUserCredentialsHealth(apiKey) { - const credentials = getUserCredentials(apiKey); - const results = []; - - for (const credential of credentials) { - try { - const result = await syncCredentialHealthFromPool(apiKey, credential); - results.push({ - id: credential.id, - isHealthy: result.isHealthy, - message: result.message, - addedAt: credential.addedAt // 传递 addedAt 用于资源包初始化 - }); - } catch (error) { - results.push({ - id: credential.id, - isHealthy: null, - message: '同步失败: ' + error.message, - addedAt: credential.addedAt - }); - } - } - - // 同步资源包状态并更新 Key 的 bonusRemaining - const bonusSync = await syncCredentialBonuses(apiKey, results); - await updateBonusRemaining(apiKey, bonusSync.bonusRemaining); - - return results; -} - -/** - * 启动定时健康检查 - */ -export function startHealthCheckScheduler() { - if (healthCheckTimer) { - clearInterval(healthCheckTimer); - } - - // 启动后延迟 30 秒执行第一次同步 - setTimeout(async () => { - logger.info('[API Potluck] Running initial health sync from pool...'); - const result = await checkAllCredentialsHealth(); - logger.info(`[API Potluck] Health sync complete: ${result.healthy}/${result.total} healthy`); - }, 30000); - - // 定时同步 - healthCheckTimer = setInterval(async () => { - logger.info('[API Potluck] Running scheduled health sync from pool...'); - const result = await checkAllCredentialsHealth(); - logger.info(`[API Potluck] Health sync complete: ${result.healthy}/${result.total} healthy`); - }, HEALTH_CHECK_INTERVAL); - - logger.info(`[API Potluck] Health sync scheduler started (interval: ${HEALTH_CHECK_INTERVAL / 1000}s)`); -} - -/** - * 停止定时健康检查 - */ -export function stopHealthCheckScheduler() { - if (healthCheckTimer) { - clearInterval(healthCheckTimer); - healthCheckTimer = null; - logger.info('[API Potluck] Health sync scheduler stopped'); - } -} - -// 导出批量检查函数供 API 使用 -export { checkUserCredentialsHealth }; diff --git a/src/plugins/api-potluck/index.js b/src/plugins/api-potluck/index.js index 43c7ace..93c2bd9 100644 --- a/src/plugins/api-potluck/index.js +++ b/src/plugins/api-potluck/index.js @@ -30,17 +30,16 @@ import { sendPotluckError } from './middleware.js'; -import { consumeBonus, getConfig } from './user-data-manager.js'; import logger from '../../utils/logger.js'; -import { handlePotluckApiRoutes, handlePotluckUserApiRoutes, startHealthCheckScheduler, stopHealthCheckScheduler } from './api-routes.js'; +import { handlePotluckApiRoutes, handlePotluckUserApiRoutes } from './api-routes.js'; /** * 插件定义 */ const apiPotluckPlugin = { name: 'api-potluck', - version: '1.0.1', + version: '1.0.2', description: 'API 大锅饭 - Key 管理和用量统计插件
管理端:potluck.html
用户端:potluck-user.html', // 插件类型:认证插件 @@ -55,10 +54,6 @@ const apiPotluckPlugin = { */ async init(config) { logger.info('[API Potluck Plugin] Initializing...'); - // 注入配置获取函数 - setConfigGetter(getConfig); - // 启动定时健康检查 - startHealthCheckScheduler(); }, /** @@ -66,8 +61,6 @@ const apiPotluckPlugin = { */ async destroy() { logger.info('[API Potluck Plugin] Destroying...'); - // 停止定时健康检查 - stopHealthCheckScheduler(); }, /** @@ -155,21 +148,24 @@ const apiPotluckPlugin = { hooks: { /** * 内容生成后钩子 - 记录用量 - * @param {Object} config - 服务器配置 + * @param {Object} hookContext - 钩子上下文,包含请求和模型信息 */ - async onContentGenerated(config) { - if (config.potluckApiKey) { + async onContentGenerated(hookContext) { + if (hookContext.potluckApiKey) { try { - // 传入资源包消耗回调 - await incrementUsage(config.potluckApiKey, async (apiKey) => { - await consumeBonus(apiKey); - }); + // 传入提供商和模型信息 + await incrementUsage( + hookContext.potluckApiKey, + hookContext.toProvider, + hookContext.model + ); } catch (e) { // 静默失败,不影响主流程 logger.error('[API Potluck Plugin] Failed to record usage:', e.message); } } } + }, // 导出内部函数供外部使用(可选) @@ -186,7 +182,6 @@ const apiPotluckPlugin = { incrementUsage, getStats, KEY_PREFIX, - getConfig, extractPotluckKey, isPotluckRequest } @@ -208,7 +203,6 @@ export { incrementUsage, getStats, KEY_PREFIX, - getConfig, extractPotluckKey, isPotluckRequest -}; \ No newline at end of file +}; diff --git a/src/plugins/api-potluck/key-manager.js b/src/plugins/api-potluck/key-manager.js index d91abe0..b2f0ec1 100644 --- a/src/plugins/api-potluck/key-manager.js +++ b/src/plugins/api-potluck/key-manager.js @@ -9,11 +9,11 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; import path from 'path'; import crypto from 'crypto'; -// 配置常量 +// 配置文件路径 const KEYS_STORE_FILE = path.join(process.cwd(), 'configs', 'api-potluck-keys.json'); const KEY_PREFIX = 'maki_'; -// 默认配置(会被 user-data-manager 的配置覆盖) +// 默认配置 const DEFAULT_CONFIG = { defaultDailyLimit: 500, persistInterval: 5000 @@ -56,18 +56,6 @@ function ensureLoaded() { if (existsSync(KEYS_STORE_FILE)) { const content = readFileSync(KEYS_STORE_FILE, 'utf8'); keyStore = JSON.parse(content); - // 兼容历史数据:为旧 Key 添加 bonusRemaining 字段 - let needsMigration = false; - for (const keyData of Object.values(keyStore.keys)) { - if (keyData.bonusRemaining === undefined) { - keyData.bonusRemaining = 0; - needsMigration = true; - } - } - if (needsMigration) { - logger.info('[API Potluck] Migrated legacy keys: added bonusRemaining field'); - markDirty(); - } } else { keyStore = { keys: {} }; syncWriteToFile(); @@ -178,6 +166,7 @@ function checkAndResetDailyCount(keyData) { /** * 创建新的 API Key + * @param {string} name - Key 名称 * @param {number} [dailyLimit] - 每日限额,不传则使用配置的默认值 */ @@ -199,8 +188,7 @@ export async function createKey(name = '', dailyLimit = null) { totalUsage: 0, lastResetDate: today, lastUsedAt: null, - enabled: true, - bonusRemaining: 0 // 剩余资源包总次数(由同步检查更新) + enabled: true }; keyStore.keys[apiKey] = keyData; @@ -333,7 +321,7 @@ export async function regenerateKey(oldKeyId) { } /** - * 验证 API Key 是否有效且有配额(每日限额 + 资源包) + * 验证 API Key 是否有效且有配额 */ export async function validateKey(apiKey) { ensureLoaded(); @@ -349,13 +337,7 @@ export async function validateKey(apiKey) { // 检查每日限额 if (keyData.todayUsage < keyData.dailyLimit) { - return { valid: true, keyData, useBonus: false }; - } - - // 每日限额用尽,检查资源包 - const bonusRemaining = keyData.bonusRemaining || 0; - if (bonusRemaining > 0) { - return { valid: true, keyData, useBonus: true, bonusRemaining }; + return { valid: true, keyData }; } return { valid: false, reason: 'quota_exceeded', keyData }; @@ -363,47 +345,55 @@ export async function validateKey(apiKey) { /** * 增加 Key 的使用次数(原子操作,直接修改内存) - * 优先消耗每日限额,用尽后消耗资源包 * @param {string} apiKey - API Key - * @param {Function} [onBonusUsed] - 资源包消耗回调,用于更新 data 中的 usedCount + * @param {string} provider - 使用的提供商 + * @param {string} model - 使用的模型 */ -export async function incrementUsage(apiKey, onBonusUsed = null) { +export async function incrementUsage(apiKey, provider = 'unknown', model = 'unknown') { ensureLoaded(); const keyData = keyStore.keys[apiKey]; if (!keyData) return null; checkAndResetDailyCount(keyData); - let usedBonus = false; - - // 优先消耗每日限额 + // 消耗每日限额 if (keyData.todayUsage < keyData.dailyLimit) { keyData.todayUsage += 1; } else { - // 每日限额用尽,消耗资源包 - const bonusRemaining = keyData.bonusRemaining || 0; - - if (bonusRemaining > 0) { - keyData.bonusRemaining = bonusRemaining - 1; - usedBonus = true; - - // 触发回调更新 data 中的 usedCount - if (onBonusUsed) { - await onBonusUsed(apiKey); - } - } else { - // 无可用配额 - return null; - } + // 每日限额用尽 + return null; } keyData.totalUsage += 1; keyData.lastUsedAt = new Date().toISOString(); + + // 记录个人按天统计 (每个 Key 独立) + const today = getTodayDateString(); + if (!keyData.usageHistory) keyData.usageHistory = {}; + if (!keyData.usageHistory[today]) { + keyData.usageHistory[today] = { providers: {}, models: {} }; + } + + // 确保 provider 和 model 是字符串 + const pName = String(provider || 'unknown'); + const mName = String(model || 'unknown'); + + const userHistory = keyData.usageHistory[today]; + userHistory.providers[pName] = (userHistory.providers[pName] || 0) + 1; + userHistory.models[mName] = (userHistory.models[mName] || 0) + 1; + + // 清理该 Key 的过期历史 (保留 7 天) + const userDates = Object.keys(keyData.usageHistory).sort(); + if (userDates.length > 7) { + const dropDates = userDates.slice(0, userDates.length - 7); + dropDates.forEach(d => delete keyData.usageHistory[d]); + } + markDirty(); return { ...keyData, - usedBonus + usedBonus: false }; } @@ -414,12 +404,36 @@ export async function getStats() { ensureLoaded(); const keys = Object.values(keyStore.keys); let enabledKeys = 0, todayTotalUsage = 0, totalUsage = 0; + const aggregatedHistory = {}; for (const key of keys) { checkAndResetDailyCount(key); if (key.enabled) enabledKeys++; todayTotalUsage += key.todayUsage; totalUsage += key.totalUsage; + + // 汇总每个 Key 的历史数据 + if (key.usageHistory) { + Object.entries(key.usageHistory).forEach(([date, history]) => { + if (!aggregatedHistory[date]) { + aggregatedHistory[date] = { providers: {}, models: {} }; + } + + // 汇总提供商 + if (history.providers) { + Object.entries(history.providers).forEach(([p, count]) => { + aggregatedHistory[date].providers[p] = (aggregatedHistory[date].providers[p] || 0) + count; + }); + } + + // 汇总模型 + if (history.models) { + Object.entries(history.models).forEach(([m, count]) => { + aggregatedHistory[date].models[m] = (aggregatedHistory[date].models[m] || 0) + count; + }); + } + }); + } } return { @@ -427,48 +441,11 @@ export async function getStats() { enabledKeys, disabledKeys: keys.length - enabledKeys, todayTotalUsage, - totalUsage + totalUsage, + usageHistory: aggregatedHistory }; } -// ============ 凭证资源包管理 ============ - -/** - * 更新 Key 的剩余资源包次数(由同步检查调用) - * @param {string} keyId - Key ID - * @param {number} bonusRemaining - 剩余资源包总次数 - * @returns {Promise} - */ -export async function updateBonusRemaining(keyId, bonusRemaining) { - ensureLoaded(); - const keyData = keyStore.keys[keyId]; - if (!keyData) return false; - - keyData.bonusRemaining = Math.max(0, bonusRemaining); - markDirty(); - return true; -} - -/** - * 获取 Key 的资源包信息 - * @param {string} keyId - Key ID - * @param {Function} getConfigFn - 获取配置的函数(从 user-data-manager 传入) - * @returns {Promise} - */ -export async function getBonusInfo(keyId, getConfigFn = null) { - ensureLoaded(); - const keyData = keyStore.keys[keyId]; - if (!keyData) return null; - - // 从 user-data-manager 获取配置 - const config = getConfigFn ? getConfigFn() : { bonusPerCredential: 300, bonusValidityDays: 30 }; - - return { - bonusRemaining: keyData.bonusRemaining || 0, - bonusPerCredential: config.bonusPerCredential, - validityDays: config.bonusValidityDays - }; -} /** * 批量更新所有 Key 的每日限额 diff --git a/src/plugins/api-potluck/middleware.js b/src/plugins/api-potluck/middleware.js index c983b3b..146d965 100644 --- a/src/plugins/api-potluck/middleware.js +++ b/src/plugins/api-potluck/middleware.js @@ -4,6 +4,7 @@ */ import { validateKey, incrementUsage, KEY_PREFIX } from './key-manager.js'; +import logger from '../../utils/logger.js'; /** * 从请求中提取 Potluck API Key diff --git a/src/plugins/api-potluck/user-data-manager.js b/src/plugins/api-potluck/user-data-manager.js index d2ec4fe..ae8775c 100644 --- a/src/plugins/api-potluck/user-data-manager.js +++ b/src/plugins/api-potluck/user-data-manager.js @@ -1,722 +1,6 @@ /** * API 大锅饭 - 用户数据管理模块 - * 管理用户关联的凭据文件路径和资源包 - * 使用 Mutex 解决并发问题 + * 注意:此模块已禁用,所有凭证管理和资源包相关功能已移除 */ -import { promises as fs } from 'fs'; -import logger from '../../utils/logger.js'; -import { existsSync, readFileSync, writeFileSync, watch } from 'fs'; -import path from 'path'; - -// 配置文件路径 -const USER_DATA_FILE = path.join(process.cwd(), 'configs', 'api-potluck-data.json'); - -// 默认配置值 -const DEFAULT_CONFIG = { - defaultDailyLimit: 500, - bonusPerCredential: 300, - bonusValidityDays: 30, - persistInterval: 5000 -}; - -// 内存缓存 -let userDataStore = null; -let isDirty = false; -let isWriting = false; -let persistTimer = null; -let fileWatcher = null; -let currentPersistInterval = DEFAULT_CONFIG.persistInterval; - -// ============ 简易 Mutex 实现 ============ -class SimpleMutex { - constructor() { - this._locked = false; - this._waiting = []; - } - - async acquire() { - return new Promise((resolve) => { - if (!this._locked) { - this._locked = true; - resolve(); - } else { - this._waiting.push(resolve); - } - }); - } - - release() { - if (this._waiting.length > 0) { - const next = this._waiting.shift(); - next(); - } else { - this._locked = false; - } - } - - async runExclusive(fn) { - await this.acquire(); - try { - return await fn(); - } finally { - this.release(); - } - } -} - -// 全局锁:用于资源包消耗操作 -const bonusMutex = new SimpleMutex(); - -// ============ 配置管理 ============ - -/** - * 获取完整配置(支持热更新) - */ -function getFullConfig() { - ensureLoaded(); - const config = userDataStore.config || {}; - return { - defaultDailyLimit: config.defaultDailyLimit ?? DEFAULT_CONFIG.defaultDailyLimit, - bonusPerCredential: config.bonusPerCredential ?? DEFAULT_CONFIG.bonusPerCredential, - bonusValidityDays: config.bonusValidityDays ?? DEFAULT_CONFIG.bonusValidityDays, - persistInterval: config.persistInterval ?? DEFAULT_CONFIG.persistInterval - }; -} - -/** - * 获取资源包配置(兼容旧接口) - */ -function getBonusConfig() { - const config = getFullConfig(); - return { - bonusPerCredential: config.bonusPerCredential, - bonusValidityDays: config.bonusValidityDays - }; -} - -/** - * 更新配置 - * @param {Object} newConfig - 新配置 - * @returns {Object} 更新后的完整配置 - */ -export async function updateConfig(newConfig) { - ensureLoaded(); - - if (!userDataStore.config) { - userDataStore.config = {}; - } - - // 验证并更新各配置项 - if (typeof newConfig.defaultDailyLimit === 'number' && newConfig.defaultDailyLimit > 0) { - userDataStore.config.defaultDailyLimit = newConfig.defaultDailyLimit; - } - if (typeof newConfig.bonusPerCredential === 'number' && newConfig.bonusPerCredential >= 0) { - userDataStore.config.bonusPerCredential = newConfig.bonusPerCredential; - } - if (typeof newConfig.bonusValidityDays === 'number' && newConfig.bonusValidityDays > 0) { - userDataStore.config.bonusValidityDays = newConfig.bonusValidityDays; - } - if (typeof newConfig.persistInterval === 'number' && newConfig.persistInterval >= 1000) { - userDataStore.config.persistInterval = newConfig.persistInterval; - // 更新持久化定时器 - updatePersistTimer(newConfig.persistInterval); - } - - markDirty(); - await persistIfDirty(); - - const updatedConfig = getFullConfig(); - logger.info(`[API Potluck UserData] Config updated:`, updatedConfig); - return updatedConfig; -} - -/** - * 更新持久化定时器间隔 - */ -function updatePersistTimer(newInterval) { - if (newInterval === currentPersistInterval) return; - - currentPersistInterval = newInterval; - if (persistTimer) { - clearInterval(persistTimer); - persistTimer = setInterval(persistIfDirty, currentPersistInterval); - logger.info(`[API Potluck UserData] Persist interval updated to ${currentPersistInterval}ms`); - } -} - -/** - * 获取当前配置(对外暴露) - */ -export function getConfig() { - return getFullConfig(); -} - -/** - * 兼容旧接口:更新资源包配置 - */ -export async function updateBonusConfig(newConfig) { - return updateConfig(newConfig); -} - -/** - * 初始化:从文件加载数据到内存 - */ -function ensureLoaded() { - if (userDataStore !== null) return; - try { - if (existsSync(USER_DATA_FILE)) { - const content = readFileSync(USER_DATA_FILE, 'utf8'); - userDataStore = JSON.parse(content); - // 兼容旧数据:确保 config 和 users 存在 - if (!userDataStore.config) { - userDataStore.config = {}; - } - if (!userDataStore.users) { - userDataStore.users = {}; - } - } else { - userDataStore = { config: {}, users: {} }; - syncWriteToFile(); - } - } catch (error) { - logger.error('[API Potluck UserData] Failed to load user data:', error.message); - userDataStore = { config: {}, users: {} }; - } - - // 获取配置的持久化间隔 - const config = userDataStore.config || {}; - currentPersistInterval = config.persistInterval ?? DEFAULT_CONFIG.persistInterval; - - // 启动定期持久化 - if (!persistTimer) { - persistTimer = setInterval(persistIfDirty, currentPersistInterval); - } - // 启动文件监听(热更新) - startFileWatcher(); -} - -/** - * 同步写入文件(仅初始化时使用) - */ -function syncWriteToFile() { - try { - const dir = path.dirname(USER_DATA_FILE); - if (!existsSync(dir)) { - require('fs').mkdirSync(dir, { recursive: true }); - } - writeFileSync(USER_DATA_FILE, JSON.stringify(userDataStore, null, 2), 'utf8'); - } catch (error) { - logger.error('[API Potluck UserData] Sync write failed:', error.message); - } -} - -/** - * 异步持久化(带写锁) - */ -async function persistIfDirty() { - if (!isDirty || isWriting || userDataStore === null) return; - isWriting = true; - try { - const dir = path.dirname(USER_DATA_FILE); - if (!existsSync(dir)) { - await fs.mkdir(dir, { recursive: true }); - } - const tempFile = USER_DATA_FILE + '.tmp'; - await fs.writeFile(tempFile, JSON.stringify(userDataStore, null, 2), 'utf8'); - await fs.rename(tempFile, USER_DATA_FILE); - isDirty = false; - } catch (error) { - logger.error('[API Potluck UserData] Persist failed:', error.message); - } finally { - isWriting = false; - } -} - -/** - * 标记数据已修改 - */ -function markDirty() { - isDirty = true; -} - -/** - * 启动文件监听(热更新配置) - */ -let lastReloadTime = 0; -function startFileWatcher() { - if (fileWatcher) return; - - try { - fileWatcher = watch(USER_DATA_FILE, { persistent: false }, (eventType) => { - if (eventType !== 'change') return; - - // 防抖:忽略自己写入触发的事件 - const now = Date.now(); - if (now - lastReloadTime < 1000 || isWriting) return; - lastReloadTime = now; - - // 重新加载配置部分 - try { - const content = readFileSync(USER_DATA_FILE, 'utf8'); - const newData = JSON.parse(content); - - // 只热更新 config 部分,不覆盖内存中的 users 数据 - if (newData.config) { - const oldConfig = userDataStore.config || {}; - const newConfig = newData.config; - - // 检查配置是否有变化 - if (JSON.stringify(oldConfig) !== JSON.stringify(newConfig)) { - userDataStore.config = newConfig; - logger.info('[API Potluck UserData] Config hot-reloaded:', getBonusConfig()); - } - } - } catch (error) { - logger.error('[API Potluck UserData] Hot-reload failed:', error.message); - } - }); - - logger.info('[API Potluck UserData] File watcher started for config hot-reload'); - } catch (error) { - logger.error('[API Potluck UserData] Failed to start file watcher:', error.message); - } -} - -/** - * 停止文件监听 - */ -export function stopFileWatcher() { - if (fileWatcher) { - fileWatcher.close(); - fileWatcher = null; - } -} - -/** - * 获取用户数据 - * @param {string} apiKey - 用户的 API Key - * @returns {Object|null} - */ -export function getUserData(apiKey) { - ensureLoaded(); - return userDataStore.users[apiKey] || null; -} - -/** - * 初始化用户数据(如果不存在) - * @param {string} apiKey - 用户的 API Key - * @returns {Object} - */ -export function ensureUserData(apiKey) { - ensureLoaded(); - if (!userDataStore.users[apiKey]) { - userDataStore.users[apiKey] = { - credentials: [], - credentialBonuses: [], - createdAt: new Date().toISOString() - }; - markDirty(); - } - // 兼容旧数据:添加 credentialBonuses 数组 - if (!userDataStore.users[apiKey].credentialBonuses) { - userDataStore.users[apiKey].credentialBonuses = []; - markDirty(); - } - return userDataStore.users[apiKey]; -} - -/** - * 添加凭据路径到用户 - * @param {string} apiKey - 用户的 API Key - * @param {Object} credentialInfo - 凭据信息 - * @param {string} credentialInfo.path - 凭据文件路径 - * @param {string} credentialInfo.provider - 提供商类型 (如 'claude-kiro-oauth') - * @param {string} [credentialInfo.authMethod] - 认证方式 (如 'builder-id', 'google', 'github') - * @returns {Object} 添加的凭据信息 - */ -export async function addUserCredential(apiKey, credentialInfo) { - ensureLoaded(); - const userData = ensureUserData(apiKey); - - // 检查是否已存在相同路径 - const existingIndex = userData.credentials.findIndex(c => c.path === credentialInfo.path); - - // 只保留核心字段,健康状态从主服务实时获取 - const credential = { - id: `cred_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`, - path: credentialInfo.path, - provider: credentialInfo.provider || 'claude-kiro-oauth', - authMethod: credentialInfo.authMethod || 'unknown', - addedAt: new Date().toISOString() - }; - - if (existingIndex >= 0) { - // 更新已存在的凭据,保留原有 id 和 addedAt - credential.id = userData.credentials[existingIndex].id; - credential.addedAt = userData.credentials[existingIndex].addedAt; - userData.credentials[existingIndex] = credential; - } else { - userData.credentials.push(credential); - } - - markDirty(); - await persistIfDirty(); - - return credential; -} - -/** - * 移除用户凭据 - * @param {string} apiKey - 用户的 API Key - * @param {string} credentialId - 凭据 ID - * @returns {boolean} - */ -export async function removeUserCredential(apiKey, credentialId) { - ensureLoaded(); - const userData = userDataStore.users[apiKey]; - if (!userData) return false; - - const index = userData.credentials.findIndex(c => c.id === credentialId); - if (index === -1) return false; - - userData.credentials.splice(index, 1); - markDirty(); - await persistIfDirty(); - - return true; -} - -/** - * 获取用户的所有凭据 - * @param {string} apiKey - 用户的 API Key - * @returns {Array} - */ -export function getUserCredentials(apiKey) { - ensureLoaded(); - const userData = userDataStore.users[apiKey]; - return userData ? userData.credentials : []; -} - -/** - * 通过路径查找凭据 - * @param {string} apiKey - 用户的 API Key - * @param {string} credPath - 凭据文件路径 - * @returns {Object|null} - */ -export function findCredentialByPath(apiKey, credPath) { - ensureLoaded(); - const userData = userDataStore.users[apiKey]; - if (!userData) return null; - - return userData.credentials.find(c => c.path === credPath) || null; -} - -/** - * 检查凭据路径是否已被任何用户使用 - * @param {string} credPath - 凭据文件路径 - * @returns {{exists: boolean, apiKey?: string}} - */ -export function isCredentialPathUsed(credPath) { - ensureLoaded(); - for (const [apiKey, userData] of Object.entries(userDataStore.users)) { - const found = userData.credentials.find(c => c.path === credPath); - if (found) { - return { exists: true, apiKey }; - } - } - return { exists: false }; -} - -/** - * 迁移用户凭据到新 Key(用于 Key 重置时) - * @param {string} oldApiKey - 旧 API Key - * @param {string} newApiKey - 新 API Key - * @returns {Promise} - */ -export async function migrateUserCredentials(oldApiKey, newApiKey) { - ensureLoaded(); - const oldUserData = userDataStore.users[oldApiKey]; - if (!oldUserData) return false; - - // 将旧用户数据迁移到新 Key - userDataStore.users[newApiKey] = { - ...oldUserData, - migratedFrom: oldApiKey.substring(0, 12) + '...', - migratedAt: new Date().toISOString() - }; - - // 删除旧用户数据 - delete userDataStore.users[oldApiKey]; - - markDirty(); - await persistIfDirty(); - - logger.info(`[API Potluck UserData] Migrated credentials from ${oldApiKey.substring(0, 12)}... to ${newApiKey.substring(0, 12)}...`); - return true; -} - -/** - * 获取所有用户及其凭据(用于批量健康检查) - * @returns {Array<{apiKey: string, credentials: Array}>} - */ -export function getAllUsersCredentials() { - ensureLoaded(); - const result = []; - for (const [apiKey, userData] of Object.entries(userDataStore.users)) { - if (userData.credentials && userData.credentials.length > 0) { - result.push({ - apiKey, - credentials: userData.credentials - }); - } - } - return result; -} - -// ============ 凭证资源包管理 ============ - -/** - * 计算资源包过期时间(使用动态配置) - * @param {string} grantedAt - 授予时间 - * @returns {Date} - */ -function calculateExpiresAt(grantedAt) { - const { bonusValidityDays } = getBonusConfig(); - const granted = new Date(grantedAt); - return new Date(granted.getTime() + bonusValidityDays * 24 * 60 * 60 * 1000); -} - -/** - * 检查资源包是否过期 - * @param {Object} bonus - 资源包对象 - * @returns {boolean} - */ -function isBonusExpired(bonus) { - const expiresAt = calculateExpiresAt(bonus.grantedAt); - return new Date() > expiresAt; -} - -/** - * 为凭证添加资源包(凭证健康时调用) - * @param {string} apiKey - 用户的 API Key - * @param {string} credentialId - 凭证 ID - * @returns {Object|null} 添加的资源包信息 - */ -export async function addCredentialBonus(apiKey, credentialId) { - ensureLoaded(); - const userData = ensureUserData(apiKey); - - // 检查是否已存在 - const existing = userData.credentialBonuses.find(b => b.credentialId === credentialId); - if (existing) { - return existing; - } - - const bonus = { - credentialId, - grantedAt: new Date().toISOString(), - usedCount: 0 - }; - - userData.credentialBonuses.push(bonus); - markDirty(); - - logger.info(`[API Potluck UserData] Added bonus for credential: ${credentialId}`); - return bonus; -} - -/** - * 移除凭证资源包(凭证失效时调用) - * @param {string} apiKey - 用户的 API Key - * @param {string} credentialId - 凭证 ID - * @returns {boolean} - */ -export async function removeCredentialBonus(apiKey, credentialId) { - ensureLoaded(); - const userData = userDataStore.users[apiKey]; - if (!userData || !userData.credentialBonuses) return false; - - const index = userData.credentialBonuses.findIndex(b => b.credentialId === credentialId); - if (index === -1) return false; - - userData.credentialBonuses.splice(index, 1); - markDirty(); - - logger.info(`[API Potluck UserData] Removed bonus for credential: ${credentialId}`); - return true; -} - -/** - * 消耗资源包次数(FIFO 顺序,使用 Mutex 保证并发安全) - * @param {string} apiKey - 用户的 API Key - * @returns {boolean} 是否成功消耗 - */ -export async function consumeBonus(apiKey) { - // 使用 Mutex 保证并发安全 - return bonusMutex.runExclusive(async () => { - ensureLoaded(); - const userData = userDataStore.users[apiKey]; - if (!userData || !userData.credentialBonuses) return false; - - const { bonusPerCredential } = getBonusConfig(); - - // 按 grantedAt 排序(FIFO) - const sortedBonuses = userData.credentialBonuses - .filter(b => !isBonusExpired(b)) - .sort((a, b) => new Date(a.grantedAt) - new Date(b.grantedAt)); - - // 找到第一个有剩余次数的资源包 - for (const bonus of sortedBonuses) { - const remaining = bonusPerCredential - bonus.usedCount; - if (remaining > 0) { - bonus.usedCount += 1; - markDirty(); - return true; - } - } - - return false; - }); -} - -/** - * 计算用户的剩余资源包总次数 - * @param {string} apiKey - 用户的 API Key - * @param {Set} [healthyCredentialIds] - 健康凭证 ID 集合(可选,用于过滤) - * @returns {number} - */ -export function calculateBonusRemaining(apiKey, healthyCredentialIds = null) { - ensureLoaded(); - const userData = userDataStore.users[apiKey]; - if (!userData || !userData.credentialBonuses) return 0; - - const { bonusPerCredential } = getBonusConfig(); - - let total = 0; - for (const bonus of userData.credentialBonuses) { - // 检查是否过期 - if (isBonusExpired(bonus)) continue; - - // 如果提供了健康凭证集合,检查凭证是否健康 - if (healthyCredentialIds && !healthyCredentialIds.has(bonus.credentialId)) continue; - - const remaining = bonusPerCredential - bonus.usedCount; - if (remaining > 0) { - total += remaining; - } - } - - return total; -} - -/** - * 同步资源包状态(根据健康凭证列表) - * 兼容历史数据:为已有健康凭证创建资源包,使用凭证的 addedAt 作为 grantedAt - * @param {string} apiKey - 用户的 API Key - * @param {Array<{id: string, isHealthy: boolean, addedAt?: string}>} credentialsWithHealth - 带健康状态的凭证列表 - * @returns {{added: number, removed: number, bonusRemaining: number}} - */ -export async function syncCredentialBonuses(apiKey, credentialsWithHealth) { - ensureLoaded(); - const userData = ensureUserData(apiKey); - - let added = 0, removed = 0; - - // 获取健康凭证 ID 集合 - const healthyIds = new Set( - credentialsWithHealth - .filter(c => c.isHealthy === true) - .map(c => c.id) - ); - - // 为新的健康凭证添加资源包 - for (const cred of credentialsWithHealth) { - if (cred.isHealthy !== true) continue; - - const exists = userData.credentialBonuses.some(b => b.credentialId === cred.id); - if (!exists) { - // 使用凭证的 addedAt 作为资源包授予时间(兼容历史数据) - const grantedAt = cred.addedAt || new Date().toISOString(); - userData.credentialBonuses.push({ - credentialId: cred.id, - grantedAt: grantedAt, - usedCount: 0 - }); - added++; - logger.info(`[API Potluck UserData] Created bonus for credential ${cred.id}, grantedAt: ${grantedAt}`); - } - } - - // 移除失效凭证的资源包 - const toRemove = userData.credentialBonuses.filter(b => !healthyIds.has(b.credentialId)); - for (const bonus of toRemove) { - const idx = userData.credentialBonuses.indexOf(bonus); - if (idx !== -1) { - userData.credentialBonuses.splice(idx, 1); - removed++; - } - } - - // 清理过期资源包 - const expiredCount = userData.credentialBonuses.filter(b => isBonusExpired(b)).length; - userData.credentialBonuses = userData.credentialBonuses.filter(b => !isBonusExpired(b)); - - if (added > 0 || removed > 0 || expiredCount > 0) { - markDirty(); - } - - // 计算剩余资源包次数 - const bonusRemaining = calculateBonusRemaining(apiKey, healthyIds); - - return { added, removed, bonusRemaining }; -} - -/** - * 获取用户的资源包详情 - * @param {string} apiKey - 用户的 API Key - * @returns {Object} - */ -export function getBonusDetails(apiKey) { - ensureLoaded(); - const { bonusPerCredential, bonusValidityDays } = getBonusConfig(); - const userData = userDataStore.users[apiKey]; - if (!userData) { - return { - bonuses: [], - totalRemaining: 0, - bonusPerCredential, - validityDays: bonusValidityDays - }; - } - - const bonuses = (userData.credentialBonuses || []) - .filter(b => !isBonusExpired(b)) - .map(b => ({ - credentialId: b.credentialId, - grantedAt: b.grantedAt, - expiresAt: calculateExpiresAt(b.grantedAt).toISOString(), - usedCount: b.usedCount, - remaining: bonusPerCredential - b.usedCount - })); - - const totalRemaining = bonuses.reduce((sum, b) => sum + Math.max(0, b.remaining), 0); - - return { - bonuses, - totalRemaining, - bonusPerCredential, - validityDays: bonusValidityDays - }; -} - -/** - * 获取所有用户的 API Key 列表 - * @returns {string[]} - */ -export function getAllUserApiKeys() { - ensureLoaded(); - return Object.keys(userDataStore.users); -} - -export { USER_DATA_FILE }; +// 空模块,保留文件以避免导入错误 diff --git a/src/ui-modules/auth.js b/src/ui-modules/auth.js index 2d5aeaf..df3a6a6 100644 --- a/src/ui-modules/auth.js +++ b/src/ui-modules/auth.js @@ -4,6 +4,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import crypto from 'crypto'; import { CONFIG } from '../core/config-manager.js'; +import { getClientIp } from '../utils/common.js'; // Token存储到本地文件中 const TOKEN_STORE_FILE = path.join(process.cwd(), 'configs', 'token-store.json'); @@ -163,6 +164,84 @@ async function deleteToken(token) { } } +/** + * 管理登录尝试频率和锁定 + */ +class LoginAttemptManager { + constructor() { + this.attempts = new Map(); // IP -> { count, lastAttempt, lockoutUntil } + } + + /** + * 获取 IP 的状态 + */ + getIpStatus(ip) { + if (!this.attempts.has(ip)) { + this.attempts.set(ip, { count: 0, lastAttempt: 0, lockoutUntil: 0 }); + } + return this.attempts.get(ip); + } + + /** + * 检查是否被锁定 + */ + isLockedOut(ip) { + const status = this.getIpStatus(ip); + if (status.lockoutUntil > Date.now()) { + return { + locked: true, + remainingTime: Math.ceil((status.lockoutUntil - Date.now()) / 1000) + }; + } + // 如果锁定时间已过,重置失败次数 + if (status.lockoutUntil > 0 && status.lockoutUntil <= Date.now()) { + status.count = 0; + status.lockoutUntil = 0; + } + return { locked: false }; + } + + /** + * 检查是否请求过于频繁 + */ + isTooFrequent(ip) { + const status = this.getIpStatus(ip); + const minInterval = CONFIG.LOGIN_MIN_INTERVAL || 1000; + const now = Date.now(); + if (now - status.lastAttempt < minInterval) { + return true; + } + status.lastAttempt = now; + return false; + } + + /** + * 记录一次失败 + */ + recordFailure(ip) { + const status = this.getIpStatus(ip); + status.count++; + const maxAttempts = CONFIG.LOGIN_MAX_ATTEMPTS || 5; + const lockoutDuration = (CONFIG.LOGIN_LOCKOUT_DURATION || 1800) * 1000; + + if (status.count >= maxAttempts) { + status.lockoutUntil = Date.now() + lockoutDuration; + logger.warn(`[Auth] IP ${ip} locked out due to too many failed login attempts (${status.count})`); + return true; + } + return false; + } + + /** + * 成功后重置 + */ + reset(ip) { + this.attempts.delete(ip); + } +} + +const loginAttemptManager = new LoginAttemptManager(); + /** * 清理过期的token */ @@ -205,7 +284,39 @@ export async function checkAuth(req) { export async function handleLoginRequest(req, res) { if (req.method !== 'POST') { res.writeHead(405, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ success: false, message: 'Only POST requests are supported' })); + res.end(JSON.stringify({ + success: false, + message: 'Only POST requests are supported', + messageCode: 'login.error.postOnly' + })); + return true; + } + + const ip = getClientIp(req); + + // 1. 检查锁定状态 + const lockout = loginAttemptManager.isLockedOut(ip); + if (lockout.locked) { + logger.warn(`[Auth] Login attempt from locked IP: ${ip}, reason: account_locked, remaining: ${lockout.remainingTime}s`); + res.writeHead(429, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + message: `Account temporarily locked due to too many failed attempts. Please try again in ${lockout.remainingTime} seconds.`, + messageCode: 'login.error.locked', + messageParams: { time: lockout.remainingTime } + })); + return true; + } + + // 2. 频率限制 + if (loginAttemptManager.isTooFrequent(ip)) { + logger.warn(`[Auth] Login attempt too frequent from IP: ${ip}, reason: rate_limit`); + res.writeHead(429, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + message: 'Too many requests, please slow down.', + messageCode: 'login.error.tooFrequent' + })); return true; } @@ -214,17 +325,27 @@ export async function handleLoginRequest(req, res) { const { password } = requestData; if (!password) { + logger.warn(`[Auth] Login failed from IP: ${ip}, reason: empty_password`); res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ success: false, message: 'Password cannot be empty' })); + res.end(JSON.stringify({ + success: false, + message: 'Password cannot be empty', + messageCode: 'login.error.empty' + })); return true; } const isValid = await validateCredentials(password); if (isValid) { + logger.info(`[Auth] Login successful from IP: ${ip}`); + // 登录成功,重置计数 + loginAttemptManager.reset(ip); + // Generate simple token const token = generateToken(); - const expiryTime = getExpiryTime(); + const loginExpiry = CONFIG.LOGIN_EXPIRY || 3600; + const expiryTime = Date.now() + (loginExpiry * 1000); // Store token info to local file await saveToken(token, { @@ -238,21 +359,37 @@ export async function handleLoginRequest(req, res) { success: true, message: 'Login successful', token, - expiresIn: `${CONFIG.LOGIN_EXPIRY || 3600} seconds` + expiresIn: `${loginExpiry} seconds` })); } else { + // 登录失败,记录 + const isLocked = loginAttemptManager.recordFailure(ip); + const status = loginAttemptManager.getIpStatus(ip); + const maxAttempts = CONFIG.LOGIN_MAX_ATTEMPTS || 5; + const remaining = maxAttempts - status.count; + const lockoutDuration = CONFIG.LOGIN_LOCKOUT_DURATION || 1800; + + logger.warn(`[Auth] Login failed from IP: ${ip}, reason: incorrect_password, remaining_attempts: ${Math.max(0, remaining)}${isLocked ? ', result: locked' : ''}`); + res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, - message: 'Incorrect password, please try again' + message: isLocked + ? `Incorrect password. Account locked for ${Math.ceil(lockoutDuration / 60)} minutes.` + : `Incorrect password. ${remaining} attempts remaining.`, + messageCode: isLocked ? 'login.error.incorrectWithLock' : 'login.error.incorrectWithRemaining', + messageParams: isLocked ? { time: Math.ceil(lockoutDuration / 60) } : { count: remaining } })); } + } catch (error) { logger.error('[Auth] Login processing error:', error); + const isJsonError = error.message === 'Invalid JSON format'; res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, - message: error.message || 'Server error' + message: error.message || 'Server error', + messageCode: isJsonError ? 'login.error.invalidJson' : undefined })); } return true; diff --git a/src/ui-modules/config-scanner.js b/src/ui-modules/config-scanner.js index 1e40533..0e95e09 100644 --- a/src/ui-modules/config-scanner.js +++ b/src/ui-modules/config-scanner.js @@ -77,51 +77,96 @@ async function analyzeOAuthFile(filePath, usedPaths, currentConfig) { // 读取文件内容进行分析 let content = ''; - let type = 'oauth_credentials'; + let type = 'oauth'; // 默认为 oauth 类型 let isValid = true; let errorMessage = ''; let oauthProvider = 'unknown'; let usageInfo = getFileUsageInfo(relativePath, filename, usedPaths, currentConfig); + // 从路径预检测提供商 + const normalizedPath = relativePath.replace(/\\/g, '/').toLowerCase(); + if (normalizedPath.includes('/kiro/')) oauthProvider = 'kiro'; + else if (normalizedPath.includes('/gemini/')) oauthProvider = 'gemini'; + else if (normalizedPath.includes('/qwen/')) oauthProvider = 'qwen'; + else if (normalizedPath.includes('/antigravity/')) oauthProvider = 'antigravity'; + else if (normalizedPath.includes('/codex/')) oauthProvider = 'codex'; + else if (normalizedPath.includes('/iflow/')) oauthProvider = 'iflow'; + try { + content = await fs.readFile(filePath, 'utf8'); + + // 1. 首先尝试根据文件名识别特定类型的配置 (最高优先级) + const lowerFilename = filename.toLowerCase(); + if (lowerFilename === 'provider_pools.json' || lowerFilename === 'provider-pools.json') { + type = 'provider-pool'; + } else if (lowerFilename.includes('system_prompt') || lowerFilename.includes('system-prompt')) { + type = 'system-prompt'; + } else if (lowerFilename === 'plugins.json') { + type = 'plugins'; + } else if (lowerFilename === 'usage-cache.json') { + type = 'usage'; + } else if (lowerFilename === 'config.json') { + type = 'config'; + } else if (lowerFilename.includes('potluck-keys')) { + type = 'api-key'; + } else if (lowerFilename.includes('potluck-data')) { + type = 'database'; + } else if (lowerFilename === 'token-store.json') { + type = 'oauth'; + } + + // 2. 根据内容进一步识别和完善信息 if (ext === '.json') { - const rawContent = await fs.readFile(filePath, 'utf8'); - const jsonData = JSON.parse(rawContent); - content = rawContent; - - // 识别OAuth提供商 - if (jsonData.apiKey || jsonData.api_key) { - type = 'api_key'; - } else if (jsonData.client_id || jsonData.client_secret) { - oauthProvider = 'oauth2'; - } else if (jsonData.access_token || jsonData.refresh_token) { - oauthProvider = 'token_based'; - } else if (jsonData.credentials) { - oauthProvider = 'service_account'; - } - - if (jsonData.base_url || jsonData.endpoint) { - if (jsonData.base_url.includes('openai.com')) { - oauthProvider = 'openai'; - } else if (jsonData.base_url.includes('anthropic.com')) { - oauthProvider = 'claude'; - } else if (jsonData.base_url.includes('googleapis.com')) { - oauthProvider = 'gemini'; + try { + const jsonData = JSON.parse(content); + + // 如果文件名没识别出类型,尝试从内容识别 + if (type === 'oauth') { + if (jsonData.providerPools || jsonData.provider_pools) { + type = 'provider-pool'; + } else if (jsonData.apiKey || jsonData.api_key) { + type = 'api-key'; + } } + + // 识别具体的提供商/认证方式 + if (jsonData.client_id || jsonData.client_secret) { + if (oauthProvider === 'unknown') oauthProvider = 'oauth2'; + } else if (jsonData.access_token || jsonData.refresh_token) { + if (oauthProvider === 'unknown') oauthProvider = 'token_based'; + } else if (jsonData.credentials) { + if (oauthProvider === 'unknown') oauthProvider = 'service_account'; + } else if (jsonData.apiKey || jsonData.api_key) { + if (oauthProvider === 'unknown') oauthProvider = 'api_key'; + } + + if (jsonData.base_url || jsonData.endpoint) { + const baseUrl = (jsonData.base_url || jsonData.endpoint).toLowerCase(); + if (baseUrl.includes('openai.com')) { + oauthProvider = 'openai'; + } else if (baseUrl.includes('anthropic.com')) { + oauthProvider = 'claude'; + } else if (baseUrl.includes('googleapis.com')) { + oauthProvider = 'gemini'; + } + } + } catch (jsonErr) { + isValid = false; + errorMessage = `JSON Parse Error: ${jsonErr.message}`; } } else { - content = await fs.readFile(filePath, 'utf8'); - + // 处理非 JSON 文件 if (ext === '.key' || ext === '.pem') { if (content.includes('-----BEGIN') && content.includes('PRIVATE KEY-----')) { oauthProvider = 'private_key'; } } else if (ext === '.txt') { if (content.includes('api_key') || content.includes('apikey')) { - oauthProvider = 'api_key'; + if (type === 'oauth') type = 'api-key'; + if (oauthProvider === 'unknown') oauthProvider = 'api_key'; } } else if (ext === '.oauth' || ext === '.creds') { - oauthProvider = 'oauth_credentials'; + if (oauthProvider === 'unknown') oauthProvider = 'oauth_credentials'; } } } catch (readError) { @@ -133,14 +178,14 @@ async function analyzeOAuthFile(filePath, usedPaths, currentConfig) { name: filename, path: relativePath, size: stats.size, - type: type, + type: type, // 用于前端图标显示的关键字段 provider: oauthProvider, extension: ext, modified: stats.mtime.toISOString(), isValid: isValid, errorMessage: errorMessage, isUsed: isPathUsed(relativePath, filename, usedPaths), - usageInfo: usageInfo, // 新增详细关联信息 + usageInfo: usageInfo, preview: content.substring(0, 100) + (content.length > 100 ? '...' : '') }; } catch (error) { diff --git a/src/utils/common.js b/src/utils/common.js index 84f12a4..745bc6b 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -988,21 +988,19 @@ export async function handleContentGenerationRequest(req, res, service, endpoint await handleUnaryRequest(res, service, model, processedRequestBody, fromProvider, toProvider, CONFIG.PROMPT_LOG_MODE, PROMPT_LOG_FILENAME, providerPoolManager, actualUuid, actualCustomName, retryContext); } - if (CONFIG?._monitorRequestId) { - // 执行插件钩子:内容生成后 - try { - const pluginManager = getPluginManager(); - await pluginManager.executeHook('onContentGenerated', { - ...CONFIG, - originalRequestBody, - processedRequestBody, - fromProvider, - toProvider, - model, - isStream - }); - } catch (e) { /* 静默失败,不影响主流程 */ } - } + // 执行插件钩子:内容生成后 + try { + const pluginManager = getPluginManager(); + await pluginManager.executeHook('onContentGenerated', { + ...CONFIG, + originalRequestBody, + processedRequestBody, + fromProvider, + toProvider, + model, + isStream + }); + } catch (e) { /* 静默失败,不影响主流程 */ } } /** diff --git a/src/utils/logger.js b/src/utils/logger.js index 3603455..afe94a2 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -1,6 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { randomUUID } from 'crypto'; +import { AsyncLocalStorage } from 'node:async_hooks'; /** * 统一日志工具类 @@ -20,7 +21,7 @@ class Logger { }; this.currentLogFile = null; this.logStream = null; - this.currentRequestId = null; // 当前请求ID + this.asyncStorage = new AsyncLocalStorage(); // 使用 AsyncLocalStorage 存储请求上下文 this.requestContext = new Map(); // 存储请求上下文 this.contextTTL = 5 * 60 * 1000; // 请求上下文 TTL:5 分钟 this._contextCleanupTimer = null; @@ -32,6 +33,7 @@ class Logger { }; } + /** * 初始化日志配置 * @param {Object} config - 日志配置对象 @@ -80,7 +82,22 @@ class Logger { } /** - * 设置请求上下文 + * 在请求上下文中运行 + * @param {string} requestId - 请求ID + * @param {Function} callback - 回调函数 + * @returns {any} + */ + runWithContext(requestId, callback) { + if (!requestId) { + requestId = randomUUID().substring(0, 8); + } + this.requestContext.set(requestId, { _createdAt: Date.now() }); + this._ensureContextCleanup(); + return this.asyncStorage.run(requestId, callback); + } + + /** + * 设置请求上下文 (不推荐直接使用,建议使用 runWithContext) * @param {string} requestId - 请求ID * @param {Object} context - 上下文信息 */ @@ -88,7 +105,7 @@ class Logger { if (!requestId) { requestId = randomUUID().substring(0, 8); } - this.currentRequestId = requestId; + this.asyncStorage.enterWith(requestId); this.requestContext.set(requestId, { ...context, _createdAt: Date.now() }); this._ensureContextCleanup(); return requestId; @@ -99,8 +116,8 @@ class Logger { * @returns {string} 请求ID */ getCurrentRequestId() { - // 从上下文中获取当前请求ID - return this.currentRequestId; + // 从 AsyncLocalStorage 中获取当前请求ID + return this.asyncStorage.getStore(); } /** @@ -109,6 +126,9 @@ class Logger { * @returns {Object} 上下文信息 */ getRequestContext(requestId) { + if (!requestId) { + requestId = this.getCurrentRequestId(); + } return this.requestContext.get(requestId) || {}; } @@ -120,9 +140,11 @@ class Logger { if (requestId) { this.requestContext.delete(requestId); } - this.currentRequestId = null; + // AsyncLocalStorage 不需要手动清除,run() 会在结束时自动处理 + // 如果使用了 enterWith,则没有简单的方法在该异步路径中清除 } + /** * 启动定期清理过期请求上下文的定时器(防止内存泄漏) * 每 60 秒扫描一次,清除超过 contextTTL 的条目 diff --git a/static/app/app.js b/static/app/app.js index 26546af..2704104 100644 --- a/static/app/app.js +++ b/static/app/app.js @@ -46,7 +46,8 @@ import { import { loadConfiguration, - saveConfiguration + saveConfiguration, + generateApiKey } from './config-manager.js'; import { @@ -241,6 +242,7 @@ window.loadConfigList = loadConfigList; window.closeConfigModal = closeConfigModal; window.copyConfigContent = copyConfigContent; window.reloadConfig = reloadConfig; +window.generateApiKey = generateApiKey; // 用量管理相关全局函数 window.refreshUsage = refreshUsage; diff --git a/static/app/config-manager.js b/static/app/config-manager.js index a4af0e2..b214396 100644 --- a/static/app/config-manager.js +++ b/static/app/config-manager.js @@ -348,8 +348,30 @@ async function saveConfiguration() { } } +/** + * 自动生成 API 密钥 + */ +function generateApiKey() { + const apiKeyEl = document.getElementById('apiKey'); + if (!apiKeyEl) return; + + // 生成 32 位 16 进制随机字符串 + const array = new Uint8Array(16); + window.crypto.getRandomValues(array); + const randomKey = 'sk-' + Array.from(array, byte => byte.toString(16).padStart(2, '0')).join(''); + + apiKeyEl.value = randomKey; + + showToast(t('common.success'), t('config.apiKey.generated') || '已生成新的 API 密钥', 'success'); + + // 触发输入框的 change 事件 + apiKeyEl.dispatchEvent(new Event('input', { bubbles: true })); + apiKeyEl.dispatchEvent(new Event('change', { bubbles: true })); +} + export { loadConfiguration, saveConfiguration, - updateConfigProviderConfigs + updateConfigProviderConfigs, + generateApiKey }; diff --git a/static/app/event-handlers.js b/static/app/event-handlers.js index 69f7a71..283bdcf 100644 --- a/static/app/event-handlers.js +++ b/static/app/event-handlers.js @@ -2,7 +2,6 @@ import { elements, autoScroll, setAutoScroll, clearLogs } from './constants.js'; import { showToast } from './utils.js'; -import { fileUploadHandler } from './file-upload.js'; import { t } from './i18n.js'; import { checkUpdate, performUpdate } from './provider-manager.js'; @@ -151,7 +150,13 @@ function initEventListeners() { // 保存配置 if (elements.saveConfigBtn) { - elements.saveConfigBtn.addEventListener('click', saveConfiguration); + elements.saveConfigBtn.addEventListener('click', () => { + if (window.saveConfiguration) { + window.saveConfiguration(); + } else if (saveConfiguration) { + saveConfiguration(); + } + }); } // 重置配置 @@ -179,6 +184,18 @@ function initEventListeners() { button.addEventListener('click', handlePasswordToggle); }); + // 生成 API 密钥按钮监听 + const generateApiKeyBtn = document.getElementById('generateApiKey'); + if (generateApiKeyBtn) { + generateApiKeyBtn.addEventListener('click', () => { + if (window.generateApiKey) { + window.generateApiKey(); + } else { + console.error('generateApiKey function not found'); + } + }); + } + // 生成凭据按钮监听 document.querySelectorAll('.generate-creds-btn').forEach(button => { button.addEventListener('click', handleGenerateCreds); diff --git a/static/app/i18n.js b/static/app/i18n.js index f8c561f..70bd835 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -212,6 +212,9 @@ const translations = { // Config 'config.title': '配置管理', 'config.apiKey': 'API密钥', + 'config.apiKey.generate': '生成', + 'config.apiKey.generateTitle': '自动生成API密钥', + 'config.apiKey.generated': '已生成新的 API 密钥', 'config.apiKeyPlaceholder': '请输入API密钥', 'config.host': '监听地址', 'config.hostPlaceholder': '例如: 127.0.0.1', @@ -291,7 +294,7 @@ const translations = { 'config.advanced.loginExpiryNote': '管理后台登录后的 Token 有效期,默认 3600 秒 (1小时)', 'config.advanced.poolFilePath': '提供商池配置文件路径(不能为空)', 'config.advanced.poolFilePathPlaceholder': '默认: configs/provider_pools.json', - 'config.advanced.poolNote': '使用默认路径配置需添加一个空节点', + 'config.advanced.poolNote': '如使用客户端默认授权配置需使用空节点', 'config.advanced.maxErrorCount': '提供商最大错误次数', 'config.advanced.maxErrorCountPlaceholder': '默认: 10', 'config.advanced.maxErrorCountNote': '提供商连续错误达到此次数后将被标记为不健康,默认为 10 次', @@ -423,7 +426,7 @@ const translations = { // Providers 'providers.title': '提供商池管理', - 'providers.note': '使用默认路径配置需添加一个空节点', + 'providers.note': '如使用客户端默认授权配置需使用空节点', 'providers.activeConnections': '活动连接', 'providers.activeProviders': '活跃提供商', 'providers.healthyProviders': '健康提供商', @@ -693,8 +696,12 @@ const translations = { 'guide.flow.step2.method2.item1': '新增提供商节点', 'guide.flow.step2.method2.item2': '上传已有的授权文件', 'guide.flow.step2.method2.item3': '手动关联凭据路径', + 'guide.flow.step2.method3': '方式三:对接提供商 API', + 'guide.flow.step2.method3.item1': '在「提供商池管理」新增对应协议的节点', + 'guide.flow.step2.method3.item2': '填入 API Key 和端点 (Base URL)', + 'guide.flow.step2.method3.item3': '无需生成或上传 OAuth 授权文件', 'guide.flow.step3.title': '管理凭据', - 'guide.flow.step3.desc': '在「凭据文件管理」页面查看和管理凭据', + 'guide.flow.step3.desc': '在「凭据文件管理」页面查看和管理凭据(非授权提供商可忽略)', 'guide.flow.step3.item1': '查看已生成的凭据文件', 'guide.flow.step3.item2': '自动关联到提供商池', 'guide.flow.step3.item3': '删除无效凭据', @@ -806,6 +813,12 @@ const translations = { 'login.passwordPlaceholder': '请输入密码', 'login.error.empty': '请输入密码', 'login.error.incorrect': '密码错误,请重试', + 'login.error.incorrectWithLock': '密码错误。账户已被锁定 {time} 分钟。', + 'login.error.incorrectWithRemaining': '密码错误。还剩 {count} 次尝试机会。', + 'login.error.locked': '账户因多次尝试失败而被暂时锁定。请在 {time} 秒后重试。', + 'login.error.tooFrequent': '请求过于频繁,请稍候再试。', + 'login.error.postOnly': '仅支持 POST 请求', + 'login.error.invalidJson': '请求格式错误 (JSON)', 'login.error.failed': '登录失败,请检查网络连接', 'login.button': '登录', 'login.loggingIn': '登录中...', @@ -1022,6 +1035,9 @@ const translations = { // Config 'config.title': 'Configuration Management', 'config.apiKey': 'API Key', + 'config.apiKey.generate': 'Generate', + 'config.apiKey.generateTitle': 'Automatically generate API key', + 'config.apiKey.generated': 'New API key generated', 'config.apiKeyPlaceholder': 'Please enter API key', 'config.host': 'Listen Address', 'config.hostPlaceholder': 'e.g.: 127.0.0.1', @@ -1101,7 +1117,7 @@ const translations = { 'config.advanced.loginExpiryNote': 'Token validity period after management console login, default 3600 seconds (1 hour)', 'config.advanced.poolFilePath': 'Provider Pool Config File Path (required)', 'config.advanced.poolFilePathPlaceholder': 'Default: configs/provider_pools.json', - 'config.advanced.poolNote': 'To use default path configuration, add an empty node', + 'config.advanced.poolNote': 'If using default client authorization config, use an empty node', 'config.advanced.maxErrorCount': 'Provider Max Error Count', 'config.advanced.maxErrorCountPlaceholder': 'Default: 10', 'config.advanced.maxErrorCountNote': 'Provider will be marked as unhealthy after consecutive errors reach this count, default is 10', @@ -1233,7 +1249,7 @@ const translations = { // Providers 'providers.title': 'Provider Pool Management', - 'providers.note': 'To use default path configuration, add an empty node', + 'providers.note': 'If using default client authorization config, use an empty node', 'providers.activeConnections': 'Active Connections', 'providers.activeProviders': 'Active Providers', 'providers.healthyProviders': 'Healthy Providers', @@ -1503,8 +1519,12 @@ const translations = { 'guide.flow.step2.method2.item1': 'Add new provider node', 'guide.flow.step2.method2.item2': 'Upload existing authorization file', 'guide.flow.step2.method2.item3': 'Manually link credential path', + 'guide.flow.step2.method3': 'Method 3: Provider API Integration', + 'guide.flow.step2.method3.item1': 'Add node for corresponding protocol in "Provider Pools"', + 'guide.flow.step2.method3.item2': 'Enter API Key and Endpoint (Base URL)', + 'guide.flow.step2.method3.item3': 'No OAuth credential file generation/upload required', 'guide.flow.step3.title': 'Manage Credentials', - 'guide.flow.step3.desc': 'View and manage credentials in "Credential Files" page', + 'guide.flow.step3.desc': 'View and manage credentials in "Credential Files" (Ignore for non-OAuth providers)', 'guide.flow.step3.item1': 'View generated credential files', 'guide.flow.step3.item2': 'Auto-link to provider pool', 'guide.flow.step3.item3': 'Delete invalid credentials', @@ -1616,6 +1636,12 @@ const translations = { 'login.passwordPlaceholder': 'Please enter password', 'login.error.empty': 'Please enter password', 'login.error.incorrect': 'Incorrect password, please try again', + 'login.error.incorrectWithLock': 'Incorrect password. Account locked for {time} minutes.', + 'login.error.incorrectWithRemaining': 'Incorrect password. {count} attempts remaining.', + 'login.error.locked': 'Account temporarily locked due to too many failed attempts. Please try again in {time} seconds.', + 'login.error.tooFrequent': 'Too many requests, please slow down.', + 'login.error.postOnly': 'Only POST requests are supported', + 'login.error.invalidJson': 'Invalid request format (JSON)', 'login.error.failed': 'Login failed, please check your network connection', 'login.button': 'Login', 'login.loggingIn': 'Logging in...', diff --git a/static/app/upload-config-manager.js b/static/app/upload-config-manager.js index 736a613..5769954 100644 --- a/static/app/upload-config-manager.js +++ b/static/app/upload-config-manager.js @@ -13,6 +13,11 @@ let isLoadingConfigs = false; // 防止重复加载配置 * @param {string} statusFilter - 状态过滤 */ function searchConfigs(searchTerm = '', statusFilter = '', providerFilter = '') { + // 确保 searchTerm 是字符串,防止事件对象等非字符串被传入 + if (typeof searchTerm !== 'string') { + searchTerm = ''; + } + if (!allConfigs.length) { console.log('没有配置数据可搜索'); return; @@ -88,7 +93,11 @@ function createConfigItemElement(config, index) { const typeIcon = config.type === 'oauth' ? 'fa-key' : config.type === 'api-key' ? 'fa-lock' : config.type === 'provider-pool' ? 'fa-network-wired' : - config.type === 'system-prompt' ? 'fa-file-text' : 'fa-file-code'; + config.type === 'system-prompt' ? 'fa-file-text' : + config.type === 'plugins' ? 'fa-plug' : + config.type === 'usage' ? 'fa-chart-line' : + config.type === 'config' ? 'fa-cog' : + config.type === 'database' ? 'fa-database' : 'fa-file-code'; // 检测提供商信息 const providerInfo = detectProviderFromPath(config.path); @@ -453,6 +462,11 @@ function updateStats() { * @param {string} providerFilter - 提供商过滤 */ async function loadConfigList(searchTerm = '', statusFilter = '', providerFilter = '') { + // 确保 searchTerm 是字符串,处理事件监听器直接调用的情况 + if (typeof searchTerm !== 'string') { + searchTerm = ''; + } + // 防止重复加载 if (isLoadingConfigs) { console.log('正在加载配置列表,跳过重复调用'); @@ -912,7 +926,7 @@ function initUploadConfigManager() { } if (refreshBtn) { - refreshBtn.addEventListener('click', loadConfigList); + refreshBtn.addEventListener('click', () => loadConfigList()); } if (downloadAllBtn) { diff --git a/static/components/section-config.css b/static/components/section-config.css index 26abaf3..6cd16ca 100644 --- a/static/components/section-config.css +++ b/static/components/section-config.css @@ -55,9 +55,25 @@ textarea.form-control { } .password-input-wrapper { - position: relative; display: flex; align-items: center; + gap: 0.5rem; +} + +.input-with-toggle { + position: relative; + flex: 1; + display: flex; + align-items: center; +} + +.generate-key-btn { + flex-shrink: 0; + height: 38px; + display: flex; + align-items: center; + gap: 0.4rem; + white-space: nowrap; } .password-input-wrapper .form-control { diff --git a/static/components/section-config.html b/static/components/section-config.html index 27e8f09..8822d86 100644 --- a/static/components/section-config.html +++ b/static/components/section-config.html @@ -10,9 +10,14 @@
- - +
+
@@ -318,10 +323,12 @@
- - +
+ + +
修改后需要重新登录
diff --git a/static/components/section-guide.html b/static/components/section-guide.html index 5dfd24c..b7c3919 100644 --- a/static/components/section-guide.html +++ b/static/components/section-guide.html @@ -76,6 +76,15 @@
  • 手动关联凭据路径
  • +
    +
    +
    方式三:对接提供商 API
    +
      +
    • 在「配置管理」设置 API Key 和端点
    • +
    • 系统自动识别并对接
    • +
    • 无需手动上传凭据
    • +
    +
    diff --git a/static/components/section-upload-config.css b/static/components/section-upload-config.css index 0584546..7df8ef1 100644 --- a/static/components/section-upload-config.css +++ b/static/components/section-upload-config.css @@ -169,6 +169,10 @@ .config-item-icon-wrapper.api-key { background: rgba(16, 185, 129, 0.1); color: #10b981; } .config-item-icon-wrapper.provider-pool { background: rgba(139, 92, 246, 0.1); color: #8b5cf6; } .config-item-icon-wrapper.system-prompt { background: rgba(245, 158, 11, 0.1); color: #f59e0b; } +.config-item-icon-wrapper.plugins { background: rgba(236, 72, 153, 0.1); color: #ec4899; } +.config-item-icon-wrapper.usage { background: rgba(59, 130, 246, 0.1); color: #3b82f6; } +.config-item-icon-wrapper.config { background: rgba(107, 114, 128, 0.1); color: #6b7280; } +.config-item-icon-wrapper.database { background: rgba(16, 185, 129, 0.1); color: #10b981; } .config-item-icon-wrapper.other { background: rgba(107, 114, 128, 0.1); color: #6b7280; } .config-item-title-area { diff --git a/static/login.html b/static/login.html index 314941a..010f70f 100644 --- a/static/login.html +++ b/static/login.html @@ -304,7 +304,8 @@ // 跳转到主页 window.location.href = '/'; } else { - showError(data.message || t('login.error.incorrect')); + const errorMsg = data.messageCode ? t(data.messageCode, data.messageParams) : (data.message || t('login.error.incorrect')); + showError(errorMsg); loginButton.disabled = false; loginButton.innerHTML = t('login.button'); passwordInput.value = ''; diff --git a/static/potluck-user.html b/static/potluck-user.html index 1bc8c6f..a2aa147 100644 --- a/static/potluck-user.html +++ b/static/potluck-user.html @@ -3,29 +3,30 @@ + API 大锅饭 - 我的用量 + + + @@ -909,18 +345,27 @@ 用户版 + +