refactor(potluck): 简化 API 大锅饭系统并增强安全性和 UI

- 移除凭证管理和资源包系统,简化为基于每日限额的 Key 管理
- 新增登录安全防护(频率限制、账户锁定、IP 追踪)
- 重构日志系统使用 AsyncLocalStorage 替代全局状态
- 全面升级 UI 界面(主题切换、使用分布统计、响应式设计)
- 优化安装脚本(PowerShell 支持、手动安装指引)

BREAKING CHANGE: API Potluck 插件不再支持凭证资源包功能,所有 Key 仅基于每日限额进行配额管理。user-data-manager 模块已禁用,相关 API 端点已移除。
This commit is contained in:
hex2077 2026-03-05 17:21:47 +08:00
parent 8456f64615
commit fa19bae517
28 changed files with 1878 additions and 4173 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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

71
install-and-run.ps1 Normal file
View file

@ -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

View file

@ -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

View file

@ -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);
}
});
};
}

File diff suppressed because it is too large Load diff

View file

@ -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 管理和用量统计插件<br>管理端:<a href="potluck.html" target="_blank">potluck.html</a><br>用户端:<a href="potluck-user.html" target="_blank">potluck-user.html</a>',
// 插件类型:认证插件
@ -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
};
};

View file

@ -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<boolean>}
*/
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<Object|null>}
*/
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 的每日限额

View file

@ -4,6 +4,7 @@
*/
import { validateKey, incrementUsage, KEY_PREFIX } from './key-manager.js';
import logger from '../../utils/logger.js';
/**
* 从请求中提取 Potluck API Key

View file

@ -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<boolean>}
*/
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<string>} [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 };
// 空模块,保留文件以避免导入错误

View file

@ -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;

View file

@ -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) {

View file

@ -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) { /* 静默失败,不影响主流程 */ }
}
/**

View file

@ -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; // 请求上下文 TTL5 分钟
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 的条目

View file

@ -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;

View file

@ -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
};

View file

@ -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);

View file

@ -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...',

View file

@ -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) {

View file

@ -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 {

View file

@ -10,9 +10,14 @@
<div class="form-group password-input-group">
<label for="apiKey" data-i18n="config.apiKey">API密钥</label>
<div class="password-input-wrapper">
<input type="password" id="apiKey" class="form-control" data-i18n="config.apiKeyPlaceholder" placeholder="请输入API密钥" autocomplete="off">
<button type="button" class="password-toggle" data-target="apiKey" aria-label="显示/隐藏密码">
<i class="fas fa-eye" aria-hidden="true"></i>
<div class="input-with-toggle">
<input type="password" id="apiKey" class="form-control" data-i18n="config.apiKeyPlaceholder" placeholder="请输入API密钥" autocomplete="off">
<button type="button" class="password-toggle" data-target="apiKey" aria-label="显示/隐藏密码">
<i class="fas fa-eye" aria-hidden="true"></i>
</button>
</div>
<button type="button" class="btn btn-sm btn-secondary generate-key-btn" id="generateApiKey" data-i18n-title="config.apiKey.generateTitle" title="自动生成API密钥">
<i class="fas fa-magic"></i> <span data-i18n="config.apiKey.generate">生成</span>
</button>
</div>
</div>
@ -318,10 +323,12 @@
<div class="form-group">
<label for="adminPassword" data-i18n="config.advanced.adminPassword">后台登录密码</label>
<div class="password-input-wrapper">
<input type="password" id="adminPassword" class="form-control" autocomplete="new-password">
<button type="button" class="password-toggle" data-target="adminPassword" aria-label="显示/隐藏密码">
<i class="fas fa-eye" aria-hidden="true"></i>
</button>
<div class="input-with-toggle">
<input type="password" id="adminPassword" class="form-control" autocomplete="new-password">
<button type="button" class="password-toggle" data-target="adminPassword" aria-label="显示/隐藏密码">
<i class="fas fa-eye" aria-hidden="true"></i>
</button>
</div>
</div>
<small class="form-text" data-i18n="config.advanced.adminPasswordNote">修改后需要重新登录</small>
</div>

View file

@ -76,6 +76,15 @@
<li data-i18n="guide.flow.step2.method2.item3">手动关联凭据路径</li>
</ul>
</div>
<div class="branch-divider" data-i18n="guide.flow.step2.or"></div>
<div class="branch-option">
<div class="branch-label" data-i18n="guide.flow.step2.method3">方式三:对接提供商 API</div>
<ul>
<li data-i18n="guide.flow.step2.method3.item1">在「配置管理」设置 API Key 和端点</li>
<li data-i18n="guide.flow.step2.method3.item2">系统自动识别并对接</li>
<li data-i18n="guide.flow.step2.method3.item3">无需手动上传凭据</li>
</ul>
</div>
</div>
</div>
</div>

View file

@ -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 {

View file

@ -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 = '';

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff